Spaces:
Running on Zero
Running on Zero
Update app.py
Browse files
app.py
CHANGED
|
@@ -65,6 +65,12 @@ def set_stop_thinking():
|
|
| 65 |
print(f"[STOP-THINK] set_stop_thinking CALLED! Flag is now: {global_stop_thinking[0]}")
|
| 66 |
return gr.update(value="β‘ Forcing generation...")
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def set_kill_threads():
|
| 69 |
global_kill_threads[0] = True
|
| 70 |
print(f"[KILL] set_kill_threads CALLED! Flag is now: {global_kill_threads[0]}")
|
|
@@ -443,13 +449,26 @@ CRITICAL: Do NOT overthink. Do NOT deliberate over conditions, edge cases, or re
|
|
| 443 |
thread2 = Thread(target=run_generation, args=(inputs2, streamer2, local_stop))
|
| 444 |
thread2.start()
|
| 445 |
|
|
|
|
| 446 |
for new_text2 in streamer2:
|
| 447 |
output_text += new_text2
|
| 448 |
yield output_text, None
|
| 449 |
|
| 450 |
-
#
|
| 451 |
-
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
local_stop[0] = True
|
| 454 |
while not streamer2.text_queue.empty():
|
| 455 |
try:
|
|
@@ -1081,11 +1100,8 @@ def process_pdf(pdf_file, url_input, audio_file_input, yt_url_input, yt_cookies_
|
|
| 1081 |
</html>
|
| 1082 |
"""
|
| 1083 |
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
# Return the iframe containing the whole SPA
|
| 1088 |
-
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, extracted_audio_path
|
| 1089 |
|
| 1090 |
LANGUAGE_DATA = """Indo-European Bengali, 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, 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
|
| 1091 |
Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
|
|
@@ -1191,20 +1207,451 @@ def process_pdf_force(partial_text, pdf_file, url_input, translit_lang, translit
|
|
| 1191 |
item["audio_uri"] = None
|
| 1192 |
|
| 1193 |
progress(1.0, desc="Rendering flashcards...")
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
)
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1208 |
|
| 1209 |
|
| 1210 |
def create_demo():
|
|
@@ -1324,64 +1771,86 @@ def create_demo():
|
|
| 1324 |
with gr.Column(scale=1):
|
| 1325 |
with gr.Tabs() as input_tabs:
|
| 1326 |
with gr.Tab("Website URL", id="tab_url") as tab_url:
|
| 1327 |
-
url_input = gr.Textbox(label="Enter a Website URL π",
|
| 1328 |
-
placeholder=r"e.g. https://www.bbc.com/korean/articles/cn0p7rkvxdgo",
|
| 1329 |
value=r"https://www.bbc.com/korean/articles/cn0p7rkvxdgo")
|
| 1330 |
-
|
| 1331 |
with gr.Tab("Upload PDF", id="tab_pdf") as tab_pdf:
|
| 1332 |
pdf_input = gr.File(label="Upload Book PDF π", file_types=[".pdf"], value=example_pdf)
|
| 1333 |
-
|
| 1334 |
with gr.Tab("Upload Audio", id="tab_audio") as tab_audio:
|
| 1335 |
audio_file_input = gr.File(label="Upload Audio File π΅", file_types=[".wav", ".mp3", ".m4a", ".ogg", ".flac", ".opus", ".webm"], value=example_audio)
|
| 1336 |
gr.Markdown("*Upload a Korean audio file. It will be transcribed using Cohere ASR and vocabulary will be extracted from the transcript.*", elem_classes=["hint-text"])
|
| 1337 |
-
|
| 1338 |
with gr.Tab("YouTube Link", id="tab_yt") as tab_yt:
|
| 1339 |
-
yt_url_input = gr.Textbox(label="Enter a YouTube Link π¬",
|
| 1340 |
-
placeholder=r"e.g. https://www.youtube.com/watch?v=...",
|
| 1341 |
value="https://www.youtube.com/watch?v=9Nj7l73PBWE",
|
| 1342 |
info="Audio from the first 5 minutes will be transcribed using Cohere ASR")
|
| 1343 |
yt_cookies_input = gr.File(label="YouTube Cookies (cookies.txt)", file_types=[".txt"], value=None,
|
| 1344 |
type="filepath")
|
| 1345 |
gr.Markdown("*Optional. Helps bypass YouTube bot detection. Install the [cookies.txt](https://addons.mozilla.org/firefox/addon/cookies-txt/) extension, go to youtube.com while logged in, click the extension β 'Current Site' to export.*", elem_classes=["hint-text"])
|
| 1346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1347 |
# Track active tab
|
| 1348 |
tab_url.select(fn=lambda: "Website URL", inputs=None, outputs=active_tab)
|
| 1349 |
tab_pdf.select(fn=lambda: "Upload PDF", inputs=None, outputs=active_tab)
|
| 1350 |
tab_audio.select(fn=lambda: "Upload Audio", inputs=None, outputs=active_tab)
|
| 1351 |
tab_yt.select(fn=lambda: "YouTube Link", inputs=None, outputs=active_tab)
|
| 1352 |
-
|
|
|
|
| 1353 |
gr.Markdown("### βοΈ Customization Settings")
|
| 1354 |
max_text_char_input = gr.Slider(minimum=1000, maximum=30000, step=1000, value=1500, label="Max Input Text Length (Characters)")
|
| 1355 |
repetition_penalty_input = gr.Slider(minimum=0.1, maximum=2.0, step=0.1, value=1.2, label="Repetition Penalty")
|
| 1356 |
auto_force_chars_input = gr.Slider(minimum=1_000, maximum=10_000, step=100, value=4_000, label="Auto-force JSON after (chars of thinking)")
|
| 1357 |
-
|
| 1358 |
with gr.Accordion("π§ Advanced", open=False):
|
| 1359 |
translit_lang = gr.Dropdown(
|
| 1360 |
-
label="Word Transliteration Language",
|
| 1361 |
-
choices=LANGUAGE_CHOICES,
|
| 1362 |
value="Indo-European - English"
|
| 1363 |
)
|
| 1364 |
translit_format = gr.Dropdown(label="Transliteration Format", choices=["dashed syllable", "regular word with space"], value="dashed syllable")
|
| 1365 |
target_lang = gr.Dropdown(
|
| 1366 |
-
label="Target Language (Full App)",
|
| 1367 |
-
choices=LANGUAGE_CHOICES,
|
| 1368 |
value="Indo-European - English"
|
| 1369 |
)
|
| 1370 |
-
|
| 1371 |
with gr.Row():
|
| 1372 |
submit_btn = gr.Button("β¨ Generate Flashcards β¨", variant="primary")
|
| 1373 |
stop_thinking_btn = gr.Button("β‘ Stop thinking, Generate now", variant="secondary")
|
| 1374 |
stop_btn = gr.Button("π Stop Generation", variant="stop")
|
| 1375 |
-
|
| 1376 |
with gr.Column(scale=2):
|
| 1377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1378 |
stream_box = gr.Textbox(label="Live Model Generation π§ ", lines=10, max_lines=20, interactive=False, autoscroll=True, elem_id="stream_box")
|
| 1379 |
-
|
| 1380 |
with gr.Accordion("π Extracted Source Content", open=True):
|
| 1381 |
extracted_text_box = gr.Textbox(label="Extracted Text", lines=10, max_lines=15, interactive=False)
|
| 1382 |
extracted_images_gallery = gr.Gallery(label="Extracted Images", columns=4, height="auto", object_fit="contain")
|
| 1383 |
extracted_audio_player = gr.Audio(label="Extracted Audio (YouTube / Uploaded)", type="filepath", interactive=False)
|
| 1384 |
-
|
| 1385 |
last_source_state = gr.State(None)
|
| 1386 |
last_korean_words_state = gr.State(None)
|
| 1387 |
|
|
@@ -1391,15 +1860,30 @@ def create_demo():
|
|
| 1391 |
submit_btn.click(fn=reset_btn_text, inputs=None, outputs=[stop_thinking_btn, stop_btn], queue=False)
|
| 1392 |
|
| 1393 |
generate_event = submit_btn.click(
|
| 1394 |
-
fn=process_pdf,
|
| 1395 |
-
inputs=[pdf_input, url_input, audio_file_input, yt_url_input, yt_cookies_input, translit_lang, translit_format, target_lang, max_text_char_input, repetition_penalty_input, auto_force_chars_input, last_source_state, last_korean_words_state, active_tab],
|
| 1396 |
outputs=[output_html, last_source_state, last_korean_words_state, stream_box, extracted_text_box, extracted_images_gallery, extracted_audio_player]
|
| 1397 |
)
|
| 1398 |
-
|
| 1399 |
-
stop_thinking_btn.click(fn=set_stop_thinking, inputs=None, outputs=stop_thinking_btn, queue=False)
|
| 1400 |
-
|
|
|
|
| 1401 |
stop_btn.click(fn=set_kill_threads, inputs=None, outputs=stop_btn, queue=False)
|
| 1402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1403 |
# Force autoscroll using Custom JS
|
| 1404 |
stream_box.change(
|
| 1405 |
fn=None,
|
|
|
|
| 65 |
print(f"[STOP-THINK] set_stop_thinking CALLED! Flag is now: {global_stop_thinking[0]}")
|
| 66 |
return gr.update(value="β‘ Forcing generation...")
|
| 67 |
|
| 68 |
+
def reset_stop_thinking_after_delay():
|
| 69 |
+
"""Wait 5 seconds then restore the button text so the user can click it again."""
|
| 70 |
+
import time
|
| 71 |
+
time.sleep(5)
|
| 72 |
+
return gr.update(value="β‘ Stop thinking, Generate now")
|
| 73 |
+
|
| 74 |
def set_kill_threads():
|
| 75 |
global_kill_threads[0] = True
|
| 76 |
print(f"[KILL] set_kill_threads CALLED! Flag is now: {global_kill_threads[0]}")
|
|
|
|
| 449 |
thread2 = Thread(target=run_generation, args=(inputs2, streamer2, local_stop))
|
| 450 |
thread2.start()
|
| 451 |
|
| 452 |
+
HARD_FORCE_CHARS = 10_000
|
| 453 |
for new_text2 in streamer2:
|
| 454 |
output_text += new_text2
|
| 455 |
yield output_text, None
|
| 456 |
|
| 457 |
+
# Hard auto-force: if total output exceeds 10K chars and
|
| 458 |
+
# the text after the last ```json prefix has no closing ] or },
|
| 459 |
+
# the model is still rambling β force again automatically.
|
| 460 |
+
last_json_start = output_text.rfind('```json')
|
| 461 |
+
text_after_json = output_text[last_json_start:] if last_json_start >= 0 else ''
|
| 462 |
+
has_json_end = (']' in text_after_json or '}' in text_after_json) if text_after_json else False
|
| 463 |
+
should_hard_force = (
|
| 464 |
+
len(output_text) > HARD_FORCE_CHARS
|
| 465 |
+
and not has_json_end
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
# Allow user to force again OR hard auto-force kicks in
|
| 469 |
+
if global_stop_thinking[0] or global_kill_threads[0] or should_hard_force:
|
| 470 |
+
reason = "hard auto-force (>10K chars)" if should_hard_force and not global_stop_thinking[0] else "user/kill flag"
|
| 471 |
+
print(f"[STOP-THINK] Flag detected in forced generation loop ({reason})! Killing...")
|
| 472 |
local_stop[0] = True
|
| 473 |
while not streamer2.text_queue.empty():
|
| 474 |
try:
|
|
|
|
| 1100 |
</html>
|
| 1101 |
"""
|
| 1102 |
|
| 1103 |
+
fc_html = build_flashcard_html(vocab_list)
|
| 1104 |
+
yield fc_html, current_source_hash, vocab_list, stream_text, content_text, images, extracted_audio_path
|
|
|
|
|
|
|
|
|
|
| 1105 |
|
| 1106 |
LANGUAGE_DATA = """Indo-European Bengali, 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, 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
|
| 1107 |
Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
|
|
|
|
| 1207 |
item["audio_uri"] = None
|
| 1208 |
|
| 1209 |
progress(1.0, desc="Rendering flashcards...")
|
| 1210 |
+
fc_html = build_flashcard_html(vocab_list)
|
| 1211 |
+
yield fc_html, current_source_hash, vocab_list, stream_text, content_text, images, None
|
| 1212 |
+
|
| 1213 |
+
|
| 1214 |
+
def build_flashcard_html(vocab_list):
|
| 1215 |
+
"""Build the flashcard SPA with spaced repetition (SM-2 lite via localStorage)."""
|
| 1216 |
+
import html as _html
|
| 1217 |
+
cards_json = json.dumps(vocab_list).replace("</", "<\\/")
|
| 1218 |
+
|
| 1219 |
+
iframe_html = f"""<!DOCTYPE html>
|
| 1220 |
+
<html>
|
| 1221 |
+
<head>
|
| 1222 |
+
<meta charset="utf-8">
|
| 1223 |
+
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/uicons-regular-rounded/css/uicons-regular-rounded.css'>
|
| 1224 |
+
<style>
|
| 1225 |
+
* {{ box-sizing:border-box; margin:0; padding:0; }}
|
| 1226 |
+
body {{ background:transparent; font-family:'Outfit','Inter',sans-serif; color:#f8fafc; padding:12px 8px; }}
|
| 1227 |
+
.sr-header {{ display:flex; align-items:center; gap:10px; margin-bottom:12px; }}
|
| 1228 |
+
.sr-bar-wrap {{ flex:1; height:7px; background:rgba(255,255,255,.1); border-radius:4px; overflow:hidden; }}
|
| 1229 |
+
.sr-bar-fill {{ height:100%; background:linear-gradient(90deg,#22c55e,#3b82f6); border-radius:4px; transition:width .5s ease; }}
|
| 1230 |
+
.sr-stats-txt {{ font-size:12px; color:#94a3b8; white-space:nowrap; }}
|
| 1231 |
+
.due-btn {{ padding:5px 13px; border-radius:20px; border:1px solid rgba(56,189,248,.4); background:rgba(56,189,248,.1); color:#38bdf8; font-size:12px; font-weight:700; cursor:pointer; transition:all .2s; white-space:nowrap; }}
|
| 1232 |
+
.due-btn.active {{ background:rgba(56,189,248,.3); border-color:#38bdf8; box-shadow:0 0 10px rgba(56,189,248,.4); }}
|
| 1233 |
+
.due-btn:hover {{ background:rgba(56,189,248,.25); }}
|
| 1234 |
+
.fc-container {{ perspective:1000px; width:100%; max-width:520px; margin:0 auto; }}
|
| 1235 |
+
.flashcard {{ width:100%; min-height:330px; display:grid; transition:transform .6s cubic-bezier(.4,.2,.2,1); transform-style:preserve-3d; cursor:pointer; }}
|
| 1236 |
+
.flashcard.is-flipped {{ transform:rotateY(180deg); }}
|
| 1237 |
+
.card-face {{ grid-area:1/1; width:100%; backface-visibility:hidden; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:20px; padding:28px; box-sizing:border-box; text-align:center; }}
|
| 1238 |
+
.card-front {{ background:linear-gradient(135deg,rgba(30,41,59,.95) 0%,rgba(15,23,42,.95) 100%); border-top:2px solid rgba(139,92,246,.6); box-shadow:0 10px 40px rgba(0,0,0,.5),inset 0 0 0 1px rgba(255,255,255,.05); position:relative; }}
|
| 1239 |
+
.card-back {{ transform:rotateY(180deg); background:linear-gradient(135deg,rgba(30,58,138,.95) 0%,rgba(15,23,42,.95) 100%); border-top:2px solid rgba(56,189,248,.6); box-shadow:0 10px 40px rgba(0,0,0,.5),inset 0 0 0 1px rgba(255,255,255,.05); }}
|
| 1240 |
+
.sr-badge {{ position:absolute; top:12px; right:14px; font-size:10px; padding:3px 9px; border-radius:12px; font-weight:800; letter-spacing:.5px; }}
|
| 1241 |
+
.badge-new {{ background:rgba(139,92,246,.3); color:#c084fc; }}
|
| 1242 |
+
.badge-learning {{ background:rgba(239,68,68,.3); color:#fca5a5; }}
|
| 1243 |
+
.badge-known {{ background:rgba(34,197,94,.3); color:#86efac; }}
|
| 1244 |
+
.badge-due {{ background:rgba(251,191,36,.3); color:#fde68a; }}
|
| 1245 |
+
.ko-text {{ font-size:50px; font-weight:800; color:#fff; text-shadow:0 0 20px rgba(139,92,246,.7); margin-bottom:14px; line-height:1.2; }}
|
| 1246 |
+
.en-text {{ font-size:28px; font-weight:800; color:#fff; text-shadow:0 0 15px rgba(56,189,248,.7); margin-bottom:6px; }}
|
| 1247 |
+
.tr-text {{ font-size:17px; font-style:italic; color:#fca5a5; margin-bottom:12px; letter-spacing:1px; }}
|
| 1248 |
+
.exp-text {{ font-size:14px; color:#cbd5e1; line-height:1.6; background:rgba(0,0,0,.25); padding:12px 15px; border-radius:12px; border:1px solid rgba(255,255,255,.06); max-width:100%; }}
|
| 1249 |
+
.flip-hint {{ margin-top:14px; color:#475569; font-size:12px; display:flex; align-items:center; gap:5px; }}
|
| 1250 |
+
.audio-btn {{ margin-top:10px; padding:9px 20px; background:rgba(56,189,248,.15); color:#38bdf8; border:1px solid rgba(56,189,248,.4); border-radius:20px; font-size:13px; font-weight:700; cursor:pointer; transition:all .3s; display:flex; align-items:center; gap:6px; text-transform:uppercase; letter-spacing:.5px; }}
|
| 1251 |
+
.audio-btn:hover {{ background:rgba(56,189,248,.35); transform:scale(1.04); box-shadow:0 5px 15px rgba(56,189,248,.3); }}
|
| 1252 |
+
.copy-icon {{ cursor:pointer; color:#475569; font-size:18px; transition:color .2s,transform .2s; flex-shrink:0; }}
|
| 1253 |
+
.copy-icon:hover {{ color:#8b5cf6; transform:scale(1.15); }}
|
| 1254 |
+
.sr-actions {{ display:flex; justify-content:center; gap:10px; margin-top:18px; width:100%; max-width:520px; margin-left:auto; margin-right:auto; }}
|
| 1255 |
+
.sr-btn {{ flex:1; padding:12px 8px; border:none; border-radius:14px; font-weight:700; font-size:13px; cursor:pointer; transition:all .25s cubic-bezier(.4,0,.2,1); display:flex; align-items:center; justify-content:center; gap:5px; font-family:inherit; }}
|
| 1256 |
+
.sr-dk {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.5); color:#fca5a5; }}
|
| 1257 |
+
.sr-dk:hover {{ background:rgba(239,68,68,.4); transform:translateY(-2px); box-shadow:0 6px 20px rgba(239,68,68,.3); }}
|
| 1258 |
+
.sr-sk {{ background:rgba(100,116,139,.2); border:1px solid rgba(100,116,139,.5); color:#94a3b8; }}
|
| 1259 |
+
.sr-sk:hover {{ background:rgba(100,116,139,.4); transform:translateY(-2px); }}
|
| 1260 |
+
.sr-kn {{ background:rgba(34,197,94,.2); border:1px solid rgba(34,197,94,.5); color:#86efac; }}
|
| 1261 |
+
.sr-kn:hover {{ background:rgba(34,197,94,.4); transform:translateY(-2px); box-shadow:0 6px 20px rgba(34,197,94,.3); }}
|
| 1262 |
+
.nav-buttons {{ display:flex; justify-content:space-between; align-items:center; margin-top:10px; width:100%; max-width:520px; margin-left:auto; margin-right:auto; gap:10px; }}
|
| 1263 |
+
.nav-btn {{ padding:9px 18px; border-radius:14px; background:rgba(139,92,246,.15); border:1px solid rgba(139,92,246,.4); color:white; font-weight:700; cursor:pointer; transition:all .25s; display:flex; align-items:center; gap:5px; font-size:13px; font-family:inherit; }}
|
| 1264 |
+
.nav-btn:hover {{ background:rgba(139,92,246,.4); transform:translateY(-2px); box-shadow:0 5px 15px rgba(139,92,246,.3); }}
|
| 1265 |
+
.nav-btn:disabled {{ background:rgba(100,116,139,.1); border-color:rgba(100,116,139,.2); color:#334155; cursor:not-allowed; transform:none; box-shadow:none; }}
|
| 1266 |
+
.progress {{ text-align:center; margin-top:8px; color:#475569; font-size:13px; font-weight:600; }}
|
| 1267 |
+
</style>
|
| 1268 |
+
</head>
|
| 1269 |
+
<body>
|
| 1270 |
+
<div id="fc-app">
|
| 1271 |
+
<div class="sr-header">
|
| 1272 |
+
<div class="sr-bar-wrap"><div class="sr-bar-fill" id="sr-fill" style="width:0%"></div></div>
|
| 1273 |
+
<span class="sr-stats-txt" id="sr-stats"></span>
|
| 1274 |
+
<button class="due-btn" id="due-btn" onclick="toggleReview()">π
Due Cards</button>
|
| 1275 |
+
</div>
|
| 1276 |
+
<div class="fc-container">
|
| 1277 |
+
<div class="flashcard" id="card" onclick="flipCard()">
|
| 1278 |
+
<div class="card-face card-front">
|
| 1279 |
+
<span class="sr-badge badge-new" id="sr-badge">π NEW</span>
|
| 1280 |
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;width:100%;justify-content:center;">
|
| 1281 |
+
<div class="ko-text" id="front-text"><i class="fi fi-rr-spinner-third"></i></div>
|
| 1282 |
+
<i class="fi fi-rr-copy copy-icon" id="copy-ko" onclick="copyText('front-text',event,this)" style="display:none;"></i>
|
| 1283 |
+
</div>
|
| 1284 |
+
<button class="audio-btn" onclick="playAudio(event)" id="audio-btn" style="display:none;"><i class="fi fi-rr-play-circle"></i> Play</button>
|
| 1285 |
+
<p class="flip-hint"><i class="fi fi-rr-rotate-right"></i> Click card to flip π―</p>
|
| 1286 |
+
</div>
|
| 1287 |
+
<div class="card-face card-back">
|
| 1288 |
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;width:100%;justify-content:center;">
|
| 1289 |
+
<div class="en-text" id="back-en"></div>
|
| 1290 |
+
<i class="fi fi-rr-copy copy-icon" id="copy-en" onclick="copyText('back-en',event,this)" style="display:none;"></i>
|
| 1291 |
+
</div>
|
| 1292 |
+
<div class="tr-text" id="back-tr"></div>
|
| 1293 |
+
<div class="exp-text"><i class="fi fi-rr-lightbulb-on" style="color:#f1c40f;"></i> <span id="back-exp"></span></div>
|
| 1294 |
+
</div>
|
| 1295 |
+
</div>
|
| 1296 |
+
</div>
|
| 1297 |
+
<div class="sr-actions">
|
| 1298 |
+
<button class="sr-btn sr-dk" onclick="markDontKnow()">β Don't Know</button>
|
| 1299 |
+
<button class="sr-btn sr-sk" onclick="skipCard()">β Skip</button>
|
| 1300 |
+
<button class="sr-btn sr-kn" onclick="markKnow()">β
Know</button>
|
| 1301 |
+
</div>
|
| 1302 |
+
<div class="nav-buttons">
|
| 1303 |
+
<button class="nav-btn" id="prev-btn" onclick="prevCard()"><i class="fi fi-rr-angle-left"></i> Prev</button>
|
| 1304 |
+
<button class="nav-btn" id="next-btn" onclick="nextCard()">Next <i class="fi fi-rr-angle-right"></i></button>
|
| 1305 |
+
</div>
|
| 1306 |
+
<div class="progress" id="prog"></div>
|
| 1307 |
+
</div>
|
| 1308 |
+
<script>
|
| 1309 |
+
const ALL = {cards_json};
|
| 1310 |
+
let disp=[...ALL], idx=0, reviewMode=false;
|
| 1311 |
+
const aud=new Audio();
|
| 1312 |
+
|
| 1313 |
+
function srKey(k){{ return 'sr_'+encodeURIComponent(k); }}
|
| 1314 |
+
function getSR(k){{ const s=localStorage.getItem(srKey(k)); return s?JSON.parse(s):{{interval:1,ef:2.5,due:null,status:'new'}}; }}
|
| 1315 |
+
function setSR(k,s){{ localStorage.setItem(srKey(k),JSON.stringify(s)); }}
|
| 1316 |
+
function today(){{ return new Date().toISOString().slice(0,10); }}
|
| 1317 |
+
function daysLater(n){{ const d=new Date(); d.setDate(d.getDate()+n); return d.toISOString().slice(0,10); }}
|
| 1318 |
+
function isDue(s){{ return !s.due||s.status==='new'||s.due<=today(); }}
|
| 1319 |
+
|
| 1320 |
+
function markKnow(){{
|
| 1321 |
+
if(!disp.length) return;
|
| 1322 |
+
const k=disp[idx].korean; let s=getSR(k);
|
| 1323 |
+
s.interval=Math.round((s.interval||1)*(s.ef||2.5));
|
| 1324 |
+
s.ef=Math.min(2.5,(s.ef||2.5)+0.1);
|
| 1325 |
+
s.due=daysLater(s.interval); s.status='known';
|
| 1326 |
+
setSR(k,s); afterAction();
|
| 1327 |
+
}}
|
| 1328 |
+
function markDontKnow(){{
|
| 1329 |
+
if(!disp.length) return;
|
| 1330 |
+
const k=disp[idx].korean; let s=getSR(k);
|
| 1331 |
+
s.interval=1; s.ef=Math.max(1.3,(s.ef||2.5)-0.2);
|
| 1332 |
+
s.due=daysLater(1); s.status='learning';
|
| 1333 |
+
setSR(k,s); afterAction();
|
| 1334 |
+
}}
|
| 1335 |
+
function skipCard(){{ if(!disp.length) return; afterAction(); }}
|
| 1336 |
+
function afterAction(){{
|
| 1337 |
+
if(reviewMode){{ refreshDue(); }}
|
| 1338 |
+
else {{ if(idx<disp.length-1){{ idx++; }} else {{ idx=0; }} showCard(); }}
|
| 1339 |
+
updateHeader();
|
| 1340 |
+
}}
|
| 1341 |
+
|
| 1342 |
+
function toggleReview(){{
|
| 1343 |
+
reviewMode=!reviewMode;
|
| 1344 |
+
const btn=document.getElementById('due-btn');
|
| 1345 |
+
if(reviewMode){{ btn.classList.add('active'); btn.textContent='π All Cards'; refreshDue(); }}
|
| 1346 |
+
else{{ btn.classList.remove('active'); btn.textContent='π
Due Cards'; disp=[...ALL]; idx=0; showCard(); }}
|
| 1347 |
+
updateHeader();
|
| 1348 |
+
}}
|
| 1349 |
+
function refreshDue(){{
|
| 1350 |
+
const due=ALL.filter(c=>isDue(getSR(c.korean)));
|
| 1351 |
+
if(!due.length){{
|
| 1352 |
+
disp=[];
|
| 1353 |
+
document.getElementById('front-text').innerHTML='π All caught up!';
|
| 1354 |
+
['audio-btn','copy-ko','copy-en','sr-badge'].forEach(id=>{{ const el=document.getElementById(id); if(el) el.style.display='none'; }});
|
| 1355 |
+
document.getElementById('prev-btn').disabled=true;
|
| 1356 |
+
document.getElementById('next-btn').disabled=true;
|
| 1357 |
+
document.getElementById('prog').textContent='β¨ Nothing due today!';
|
| 1358 |
+
return;
|
| 1359 |
+
}}
|
| 1360 |
+
disp=due; if(idx>=disp.length) idx=0; showCard();
|
| 1361 |
+
}}
|
| 1362 |
+
|
| 1363 |
+
function updateHeader(){{
|
| 1364 |
+
const total=ALL.length; if(!total) return;
|
| 1365 |
+
const known=ALL.filter(c=>getSR(c.korean).status==='known').length;
|
| 1366 |
+
const due=ALL.filter(c=>isDue(getSR(c.korean))).length;
|
| 1367 |
+
document.getElementById('sr-fill').style.width=Math.round(known/total*100)+'%';
|
| 1368 |
+
document.getElementById('sr-stats').textContent=`β
${{known}}/${{total}} Β· π
${{due}} due`;
|
| 1369 |
+
if(!reviewMode){{
|
| 1370 |
+
const db=document.getElementById('due-btn');
|
| 1371 |
+
db.textContent=due>0?`π
Due (${{due}})`:'π
Due Cards';
|
| 1372 |
+
}}
|
| 1373 |
+
}}
|
| 1374 |
+
|
| 1375 |
+
function showCard(){{
|
| 1376 |
+
if(!disp.length) return;
|
| 1377 |
+
const c=disp[idx];
|
| 1378 |
+
document.getElementById('front-text').innerText=c.korean||'β';
|
| 1379 |
+
document.getElementById('back-en').innerText=c.translation||c.english||'';
|
| 1380 |
+
document.getElementById('back-tr').innerText=c.transliteration?`[${{c.transliteration}}]`:'';
|
| 1381 |
+
document.getElementById('back-exp').innerText=c.explanation||'';
|
| 1382 |
+
const s=getSR(c.korean);
|
| 1383 |
+
const badge=document.getElementById('sr-badge');
|
| 1384 |
+
badge.style.display='block';
|
| 1385 |
+
if(s.status==='known'){{ badge.textContent=isDue(s)?'β° DUE':'β
KNOWN'; badge.className=isDue(s)?'sr-badge badge-due':'sr-badge badge-known'; }}
|
| 1386 |
+
else if(s.status==='learning'){{ badge.textContent='π LEARNING'; badge.className='sr-badge badge-learning'; }}
|
| 1387 |
+
else{{ badge.textContent='π NEW'; badge.className='sr-badge badge-new'; }}
|
| 1388 |
+
document.getElementById('copy-ko').style.display='block';
|
| 1389 |
+
document.getElementById('copy-en').style.display='block';
|
| 1390 |
+
document.getElementById('prev-btn').disabled=idx===0;
|
| 1391 |
+
document.getElementById('next-btn').disabled=idx===disp.length-1;
|
| 1392 |
+
document.getElementById('prog').innerHTML=`π Card ${{idx+1}} of ${{disp.length}}`;
|
| 1393 |
+
document.getElementById('card').classList.remove('is-flipped');
|
| 1394 |
+
if(c.audio_uri){{ aud.src=c.audio_uri; document.getElementById('audio-btn').style.display='flex'; }}
|
| 1395 |
+
else {{ document.getElementById('audio-btn').style.display='none'; }}
|
| 1396 |
+
updateHeader();
|
| 1397 |
+
}}
|
| 1398 |
+
|
| 1399 |
+
function flipCard(){{ if(disp.length) document.getElementById('card').classList.toggle('is-flipped'); }}
|
| 1400 |
+
function playAudio(e){{ e.stopPropagation(); aud.play().catch(()=>{{}}); }}
|
| 1401 |
+
function nextCard(){{ if(idx<disp.length-1){{ idx++; showCard(); }} }}
|
| 1402 |
+
function prevCard(){{ if(idx>0){{ idx--; showCard(); }} }}
|
| 1403 |
+
function copyText(id,e,el){{
|
| 1404 |
+
e.stopPropagation();
|
| 1405 |
+
navigator.clipboard.writeText(document.getElementById(id).innerText).then(()=>{{
|
| 1406 |
+
const old=el.className; el.className='fi fi-rr-check copy-icon'; el.style.color='#22c55e';
|
| 1407 |
+
setTimeout(()=>{{ el.className=old; el.style.color=''; }},1500);
|
| 1408 |
+
}}).catch(()=>{{}});
|
| 1409 |
+
}}
|
| 1410 |
+
window.onload=function(){{ if(ALL.length) showCard(); updateHeader(); }};
|
| 1411 |
+
</script>
|
| 1412 |
+
</body>
|
| 1413 |
+
</html>"""
|
| 1414 |
+
|
| 1415 |
+
safe_srcdoc = _html.escape(iframe_html)
|
| 1416 |
+
return f'<iframe srcdoc="{safe_srcdoc}" style="width:100%; height:700px; border:none; overflow-y:auto;"></iframe>'
|
| 1417 |
+
|
| 1418 |
+
|
| 1419 |
+
def build_quiz_html(vocab_list):
|
| 1420 |
+
"""Build a 5-question multiple-choice quiz SPA."""
|
| 1421 |
+
import html as _html
|
| 1422 |
+
import random as rnd
|
| 1423 |
+
|
| 1424 |
+
if not vocab_list or len(vocab_list) < 2:
|
| 1425 |
+
return "<p style='color:#94a3b8;text-align:center;padding:30px;font-family:Outfit,sans-serif;font-size:16px;'>β οΈ Need at least 2 flashcards to start a quiz.<br>Generate or import a deck first!</p>"
|
| 1426 |
+
|
| 1427 |
+
nq = min(5, len(vocab_list))
|
| 1428 |
+
q_cards = rnd.sample(vocab_list, nq)
|
| 1429 |
+
quiz_data = []
|
| 1430 |
+
for qc in q_cards:
|
| 1431 |
+
correct = qc.get('translation', '') or qc.get('english', '')
|
| 1432 |
+
wrong_pool = [c for c in vocab_list if c is not qc and (c.get('translation', '') or c.get('english', '')) != correct]
|
| 1433 |
+
wrongs = rnd.sample(wrong_pool, min(3, len(wrong_pool)))
|
| 1434 |
+
choices = [correct] + [w.get('translation', '') or w.get('english', '') for w in wrongs]
|
| 1435 |
+
rnd.shuffle(choices)
|
| 1436 |
+
quiz_data.append({
|
| 1437 |
+
'korean': qc.get('korean', ''),
|
| 1438 |
+
'transliteration': qc.get('transliteration', ''),
|
| 1439 |
+
'choices': choices,
|
| 1440 |
+
'correct': choices.index(correct),
|
| 1441 |
+
})
|
| 1442 |
+
|
| 1443 |
+
quiz_json = json.dumps(quiz_data).replace("</", "<\\/")
|
| 1444 |
+
|
| 1445 |
+
iframe_html = f"""<!DOCTYPE html>
|
| 1446 |
+
<html>
|
| 1447 |
+
<head>
|
| 1448 |
+
<meta charset="utf-8">
|
| 1449 |
+
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/uicons-regular-rounded/css/uicons-regular-rounded.css'>
|
| 1450 |
+
<style>
|
| 1451 |
+
* {{ box-sizing:border-box; margin:0; padding:0; }}
|
| 1452 |
+
body {{ background:transparent; font-family:'Outfit','Inter',sans-serif; color:#f8fafc; padding:14px 10px; }}
|
| 1453 |
+
.quiz-hdr {{ text-align:center; margin-bottom:18px; }}
|
| 1454 |
+
.quiz-title {{ font-size:20px; font-weight:800; background:linear-gradient(to right,#c084fc,#60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:8px; }}
|
| 1455 |
+
.qpbar {{ width:100%; height:5px; background:rgba(255,255,255,.1); border-radius:3px; overflow:hidden; margin-bottom:4px; }}
|
| 1456 |
+
.qpfill {{ height:100%; background:linear-gradient(90deg,#8b5cf6,#3b82f6); border-radius:3px; transition:width .4s ease; }}
|
| 1457 |
+
.qptxt {{ font-size:12px; color:#64748b; }}
|
| 1458 |
+
.q-card {{ background:linear-gradient(135deg,rgba(30,41,59,.95) 0%,rgba(15,23,42,.95) 100%); border:1px solid rgba(139,92,246,.3); border-top:2px solid rgba(139,92,246,.7); border-radius:20px; padding:26px; text-align:center; margin-bottom:18px; box-shadow:0 10px 40px rgba(0,0,0,.5); }}
|
| 1459 |
+
.q-label {{ font-size:11px; text-transform:uppercase; letter-spacing:2px; color:#8b5cf6; font-weight:800; margin-bottom:10px; }}
|
| 1460 |
+
.q-word {{ font-size:52px; font-weight:800; color:#fff; text-shadow:0 0 25px rgba(139,92,246,.7); margin-bottom:7px; }}
|
| 1461 |
+
.q-tr {{ font-size:15px; color:#fca5a5; font-style:italic; letter-spacing:1px; }}
|
| 1462 |
+
.choices-grid {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:14px; }}
|
| 1463 |
+
.choice-btn {{ padding:14px 12px; border-radius:14px; border:1px solid rgba(139,92,246,.3); background:rgba(139,92,246,.1); color:#e2e8f0; font-size:14px; font-weight:600; cursor:pointer; transition:all .25s; text-align:center; line-height:1.3; font-family:inherit; }}
|
| 1464 |
+
.choice-btn:hover:not(:disabled) {{ background:rgba(139,92,246,.3); border-color:rgba(139,92,246,.7); transform:translateY(-2px); box-shadow:0 5px 15px rgba(139,92,246,.3); }}
|
| 1465 |
+
.choice-btn.correct {{ background:rgba(34,197,94,.3)!important; border-color:#22c55e!important; color:#86efac!important; transform:scale(1.02); box-shadow:0 0 20px rgba(34,197,94,.4)!important; }}
|
| 1466 |
+
.choice-btn.wrong {{ background:rgba(239,68,68,.2)!important; border-color:rgba(239,68,68,.5)!important; color:#fca5a5!important; }}
|
| 1467 |
+
.choice-btn:disabled {{ cursor:default; }}
|
| 1468 |
+
.fb-txt {{ font-size:15px; font-weight:700; text-align:center; min-height:22px; margin-bottom:6px; }}
|
| 1469 |
+
.nxt-btn {{ width:100%; padding:13px; border:none; border-radius:14px; background:linear-gradient(135deg,#8b5cf6,#3b82f6); color:white; font-size:14px; font-weight:800; cursor:pointer; transition:all .3s; letter-spacing:.5px; text-transform:uppercase; display:none; font-family:inherit; }}
|
| 1470 |
+
.nxt-btn:hover {{ transform:translateY(-2px); box-shadow:0 8px 25px rgba(139,92,246,.5); }}
|
| 1471 |
+
#score-screen {{ display:none; text-align:center; padding:28px 16px; }}
|
| 1472 |
+
.score-em {{ font-size:70px; margin-bottom:14px; animation:pop .6s cubic-bezier(.68,-.55,.265,1.55); }}
|
| 1473 |
+
@keyframes pop {{ from {{ transform:scale(0); opacity:0; }} to {{ transform:scale(1); opacity:1; }} }}
|
| 1474 |
+
.score-ttl {{ font-size:26px; font-weight:800; background:linear-gradient(to right,#c084fc,#60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:6px; }}
|
| 1475 |
+
.score-sub {{ font-size:14px; color:#94a3b8; margin-bottom:20px; }}
|
| 1476 |
+
.score-details {{ background:rgba(139,92,246,.1); border:1px solid rgba(139,92,246,.3); border-radius:16px; padding:16px; margin-bottom:20px; }}
|
| 1477 |
+
.s-row {{ display:flex; justify-content:space-between; padding:5px 0; border-bottom:1px solid rgba(255,255,255,.05); font-size:13px; }}
|
| 1478 |
+
.s-row:last-child {{ border-bottom:none; }}
|
| 1479 |
+
.s-ko {{ color:#e2e8f0; }} .s-ok {{ color:#86efac; font-weight:700; }} .s-no {{ color:#fca5a5; font-weight:700; }}
|
| 1480 |
+
.restart-btn {{ padding:13px 28px; border:none; border-radius:14px; background:linear-gradient(135deg,#8b5cf6,#3b82f6); color:white; font-size:14px; font-weight:800; cursor:pointer; transition:all .3s; text-transform:uppercase; letter-spacing:1px; font-family:inherit; }}
|
| 1481 |
+
.restart-btn:hover {{ transform:translateY(-2px); box-shadow:0 8px 25px rgba(139,92,246,.5); }}
|
| 1482 |
+
</style>
|
| 1483 |
+
</head>
|
| 1484 |
+
<body>
|
| 1485 |
+
<div id="quiz-app">
|
| 1486 |
+
<div id="question-screen">
|
| 1487 |
+
<div class="quiz-hdr">
|
| 1488 |
+
<div class="quiz-title">π§ Vocabulary Quiz</div>
|
| 1489 |
+
<div class="qpbar"><div class="qpfill" id="qp-fill" style="width:0%"></div></div>
|
| 1490 |
+
<div class="qptxt" id="qp-txt">Question 1 of {nq}</div>
|
| 1491 |
+
</div>
|
| 1492 |
+
<div class="q-card">
|
| 1493 |
+
<div class="q-label">What does this word mean?</div>
|
| 1494 |
+
<div class="q-word" id="q-word"></div>
|
| 1495 |
+
<div class="q-tr" id="q-tr"></div>
|
| 1496 |
+
</div>
|
| 1497 |
+
<div class="choices-grid" id="choices"></div>
|
| 1498 |
+
<div class="fb-txt" id="fb"></div>
|
| 1499 |
+
<button class="nxt-btn" id="nxt-btn" onclick="nextQ()">Next β</button>
|
| 1500 |
+
</div>
|
| 1501 |
+
<div id="score-screen">
|
| 1502 |
+
<div class="score-em" id="s-em"></div>
|
| 1503 |
+
<div class="score-ttl" id="s-ttl"></div>
|
| 1504 |
+
<div class="score-sub" id="s-sub"></div>
|
| 1505 |
+
<div class="score-details" id="s-det"></div>
|
| 1506 |
+
<button class="restart-btn" onclick="restart()">π Try Again</button>
|
| 1507 |
+
</div>
|
| 1508 |
+
</div>
|
| 1509 |
+
<script>
|
| 1510 |
+
const QD={quiz_json};
|
| 1511 |
+
let qi=0,score=0,res=[],answered=false;
|
| 1512 |
+
const NQ=QD.length;
|
| 1513 |
+
function loadQ(){{
|
| 1514 |
+
if(qi>=NQ){{ showScore(); return; }}
|
| 1515 |
+
answered=false;
|
| 1516 |
+
const q=QD[qi];
|
| 1517 |
+
document.getElementById('q-word').textContent=q.korean;
|
| 1518 |
+
document.getElementById('q-tr').textContent=q.transliteration?`[${{q.transliteration}}]`:'';
|
| 1519 |
+
document.getElementById('qp-fill').style.width=(qi/NQ*100)+'%';
|
| 1520 |
+
document.getElementById('qp-txt').textContent=`Question ${{qi+1}} of ${{NQ}}`;
|
| 1521 |
+
document.getElementById('fb').innerHTML='';
|
| 1522 |
+
document.getElementById('nxt-btn').style.display='none';
|
| 1523 |
+
const ch=document.getElementById('choices'); ch.innerHTML='';
|
| 1524 |
+
q.choices.forEach((c,i)=>{{
|
| 1525 |
+
const b=document.createElement('button'); b.className='choice-btn';
|
| 1526 |
+
b.textContent=c; b.onclick=()=>pick(i,b); ch.appendChild(b);
|
| 1527 |
+
}});
|
| 1528 |
+
}}
|
| 1529 |
+
function pick(ci,btn){{
|
| 1530 |
+
if(answered) return; answered=true;
|
| 1531 |
+
const q=QD[qi]; const ok=ci===q.correct;
|
| 1532 |
+
document.querySelectorAll('.choice-btn').forEach(b=>b.disabled=true);
|
| 1533 |
+
document.querySelectorAll('.choice-btn')[q.correct].classList.add('correct');
|
| 1534 |
+
if(ok){{ score++; document.getElementById('fb').innerHTML='β
<span style="color:#86efac">Correct!</span>'; res.push({{k:q.korean,ok:true}}); }}
|
| 1535 |
+
else{{ btn.classList.add('wrong'); document.getElementById('fb').innerHTML=`β <span style="color:#fca5a5">Wrong!</span> β <strong>${{q.choices[q.correct]}}</strong>`; res.push({{k:q.korean,ok:false,ans:q.choices[q.correct]}}); }}
|
| 1536 |
+
const nb=document.getElementById('nxt-btn'); nb.style.display='block';
|
| 1537 |
+
nb.textContent=qi<NQ-1?'Next Question β':'See Results π';
|
| 1538 |
+
}}
|
| 1539 |
+
function nextQ(){{ qi++; loadQ(); }}
|
| 1540 |
+
function showScore(){{
|
| 1541 |
+
document.getElementById('question-screen').style.display='none';
|
| 1542 |
+
document.getElementById('score-screen').style.display='block';
|
| 1543 |
+
const p=score/NQ;
|
| 1544 |
+
const data=p===1?['π','Perfect Score!','You nailed every question!']:p>=.8?['β','Excellent!','Almost perfect!']:p>=.6?['π','Good Work!','Keep practicing!']:p>=.4?['π','Keep Studying!','Review the flashcards!']:['πͺ','Keep Going!',"Practice makes perfect!"];
|
| 1545 |
+
document.getElementById('s-em').textContent=data[0];
|
| 1546 |
+
document.getElementById('s-ttl').textContent=`${{score}}/${{NQ}} β ${{data[1]}}`;
|
| 1547 |
+
document.getElementById('s-sub').textContent=data[2];
|
| 1548 |
+
document.getElementById('s-det').innerHTML=res.map(r=>`<div class="s-row"><span class="s-ko">${{r.k}}</span><span class="${{r.ok?'s-ok':'s-no'}}">${{r.ok?'β
Correct':'β '+r.ans}}</span></div>`).join('');
|
| 1549 |
+
}}
|
| 1550 |
+
function restart(){{
|
| 1551 |
+
qi=0; score=0; res=[]; answered=false;
|
| 1552 |
+
document.getElementById('question-screen').style.display='block';
|
| 1553 |
+
document.getElementById('score-screen').style.display='none';
|
| 1554 |
+
loadQ();
|
| 1555 |
+
}}
|
| 1556 |
+
window.onload=loadQ;
|
| 1557 |
+
</script>
|
| 1558 |
+
</body>
|
| 1559 |
+
</html>"""
|
| 1560 |
+
|
| 1561 |
+
safe_srcdoc = _html.escape(iframe_html)
|
| 1562 |
+
return f'<iframe srcdoc="{safe_srcdoc}" style="width:100%; height:700px; border:none; overflow-y:auto;"></iframe>'
|
| 1563 |
+
|
| 1564 |
+
|
| 1565 |
+
def export_json_file_fn(vocab_list):
|
| 1566 |
+
"""Export current vocab list to a JSON file for download."""
|
| 1567 |
+
if not vocab_list:
|
| 1568 |
+
gr.Warning("No flashcards to export. Generate or import a deck first!")
|
| 1569 |
+
return gr.update(visible=False)
|
| 1570 |
+
os.makedirs("log", exist_ok=True)
|
| 1571 |
+
path = os.path.join("log", "flashcards_export.json")
|
| 1572 |
+
export_data = [{k: v for k, v in item.items() if k != 'audio_uri'} for item in vocab_list]
|
| 1573 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 1574 |
+
json.dump(export_data, f, ensure_ascii=False, indent=2)
|
| 1575 |
+
return gr.update(value=path, visible=True)
|
| 1576 |
+
|
| 1577 |
+
|
| 1578 |
+
def export_anki_file_fn(vocab_list):
|
| 1579 |
+
"""Export current vocab list to an Anki .apkg file for download."""
|
| 1580 |
+
if not vocab_list:
|
| 1581 |
+
gr.Warning("No flashcards to export. Generate or import a deck first!")
|
| 1582 |
+
return gr.update(visible=False)
|
| 1583 |
+
try:
|
| 1584 |
+
import genanki
|
| 1585 |
+
import random as rnd
|
| 1586 |
+
except ImportError:
|
| 1587 |
+
gr.Warning("genanki not installed. Run: pip install genanki")
|
| 1588 |
+
return gr.update(visible=False)
|
| 1589 |
+
model = genanki.Model(
|
| 1590 |
+
rnd.randrange(1 << 30, 1 << 31),
|
| 1591 |
+
'LocalDuo Korean Vocab',
|
| 1592 |
+
fields=[{'name': 'Korean'}, {'name': 'Translation'}, {'name': 'Transliteration'}, {'name': 'Explanation'}],
|
| 1593 |
+
templates=[{
|
| 1594 |
+
'name': 'Card 1',
|
| 1595 |
+
'qfmt': '<div style="font-size:42px;text-align:center;font-weight:bold;color:#4a0e8f;padding:20px;">{{Korean}}</div>',
|
| 1596 |
+
'afmt': '{{FrontSide}}<hr id=answer><div style="font-size:24px;font-weight:bold;color:#1a56db;">{{Translation}}</div><div style="color:#888;font-style:italic;margin:8px 0;">{{Transliteration}}</div><div style="font-size:14px;color:#555;background:#f5f5f5;padding:10px;border-radius:8px;">{{Explanation}}</div>',
|
| 1597 |
+
}]
|
| 1598 |
)
|
| 1599 |
+
deck = genanki.Deck(rnd.randrange(1 << 30, 1 << 31), 'LocalDuo - Korean Vocabulary')
|
| 1600 |
+
for item in vocab_list:
|
| 1601 |
+
deck.add_note(genanki.Note(model=model, fields=[
|
| 1602 |
+
item.get('korean', ''),
|
| 1603 |
+
item.get('translation', '') or item.get('english', ''),
|
| 1604 |
+
item.get('transliteration', ''),
|
| 1605 |
+
item.get('explanation', ''),
|
| 1606 |
+
]))
|
| 1607 |
+
os.makedirs("log", exist_ok=True)
|
| 1608 |
+
path = os.path.join("log", "flashcards_export.apkg")
|
| 1609 |
+
genanki.Package(deck).write_to_file(path)
|
| 1610 |
+
return gr.update(value=path, visible=True)
|
| 1611 |
+
|
| 1612 |
+
|
| 1613 |
+
def import_deck_fn(json_file, anki_file):
|
| 1614 |
+
"""Load a flashcard deck from a JSON or Anki .apkg file."""
|
| 1615 |
+
if json_file is not None:
|
| 1616 |
+
try:
|
| 1617 |
+
with open(json_file, "r", encoding="utf-8") as f:
|
| 1618 |
+
data = json.load(f)
|
| 1619 |
+
if not isinstance(data, list):
|
| 1620 |
+
data = [data]
|
| 1621 |
+
for item in data:
|
| 1622 |
+
if 'audio_uri' not in item:
|
| 1623 |
+
item['audio_uri'] = None
|
| 1624 |
+
return build_flashcard_html(data), data
|
| 1625 |
+
except Exception as e:
|
| 1626 |
+
return f"<p style='color:#fca5a5;padding:20px;font-family:Outfit,sans-serif;'>β Error loading JSON: {e}</p>", None
|
| 1627 |
+
elif anki_file is not None:
|
| 1628 |
+
try:
|
| 1629 |
+
import zipfile, sqlite3, tempfile
|
| 1630 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 1631 |
+
with zipfile.ZipFile(anki_file, 'r') as z:
|
| 1632 |
+
z.extractall(tmpdir)
|
| 1633 |
+
db_path = os.path.join(tmpdir, 'collection.anki2')
|
| 1634 |
+
if not os.path.exists(db_path):
|
| 1635 |
+
db_path = os.path.join(tmpdir, 'collection.anki21')
|
| 1636 |
+
conn = sqlite3.connect(db_path)
|
| 1637 |
+
rows = conn.execute("SELECT flds FROM notes").fetchall()
|
| 1638 |
+
conn.close()
|
| 1639 |
+
vocab_list = []
|
| 1640 |
+
for row in rows:
|
| 1641 |
+
fields = row[0].split('\x1f')
|
| 1642 |
+
vocab_list.append({
|
| 1643 |
+
'korean': fields[0] if len(fields) > 0 else '',
|
| 1644 |
+
'translation': fields[1] if len(fields) > 1 else '',
|
| 1645 |
+
'transliteration': fields[2] if len(fields) > 2 else '',
|
| 1646 |
+
'explanation': fields[3] if len(fields) > 3 else '',
|
| 1647 |
+
'audio_uri': None,
|
| 1648 |
+
})
|
| 1649 |
+
if not vocab_list:
|
| 1650 |
+
return "<p style='color:#fca5a5;padding:20px;font-family:Outfit,sans-serif;'>β No notes found in Anki deck.</p>", None
|
| 1651 |
+
return build_flashcard_html(vocab_list), vocab_list
|
| 1652 |
+
except Exception as e:
|
| 1653 |
+
return f"<p style='color:#fca5a5;padding:20px;font-family:Outfit,sans-serif;'>β Error loading Anki deck: {e}</p>", None
|
| 1654 |
+
return "<p style='color:#94a3b8;padding:20px;text-align:center;font-family:Outfit,sans-serif;'>β οΈ Please upload a JSON or Anki (.apkg) file above.</p>", None
|
| 1655 |
|
| 1656 |
|
| 1657 |
def create_demo():
|
|
|
|
| 1771 |
with gr.Column(scale=1):
|
| 1772 |
with gr.Tabs() as input_tabs:
|
| 1773 |
with gr.Tab("Website URL", id="tab_url") as tab_url:
|
| 1774 |
+
url_input = gr.Textbox(label="Enter a Website URL π",
|
| 1775 |
+
placeholder=r"e.g. https://www.bbc.com/korean/articles/cn0p7rkvxdgo",
|
| 1776 |
value=r"https://www.bbc.com/korean/articles/cn0p7rkvxdgo")
|
| 1777 |
+
|
| 1778 |
with gr.Tab("Upload PDF", id="tab_pdf") as tab_pdf:
|
| 1779 |
pdf_input = gr.File(label="Upload Book PDF π", file_types=[".pdf"], value=example_pdf)
|
| 1780 |
+
|
| 1781 |
with gr.Tab("Upload Audio", id="tab_audio") as tab_audio:
|
| 1782 |
audio_file_input = gr.File(label="Upload Audio File π΅", file_types=[".wav", ".mp3", ".m4a", ".ogg", ".flac", ".opus", ".webm"], value=example_audio)
|
| 1783 |
gr.Markdown("*Upload a Korean audio file. It will be transcribed using Cohere ASR and vocabulary will be extracted from the transcript.*", elem_classes=["hint-text"])
|
| 1784 |
+
|
| 1785 |
with gr.Tab("YouTube Link", id="tab_yt") as tab_yt:
|
| 1786 |
+
yt_url_input = gr.Textbox(label="Enter a YouTube Link π¬",
|
| 1787 |
+
placeholder=r"e.g. https://www.youtube.com/watch?v=...",
|
| 1788 |
value="https://www.youtube.com/watch?v=9Nj7l73PBWE",
|
| 1789 |
info="Audio from the first 5 minutes will be transcribed using Cohere ASR")
|
| 1790 |
yt_cookies_input = gr.File(label="YouTube Cookies (cookies.txt)", file_types=[".txt"], value=None,
|
| 1791 |
type="filepath")
|
| 1792 |
gr.Markdown("*Optional. Helps bypass YouTube bot detection. Install the [cookies.txt](https://addons.mozilla.org/firefox/addon/cookies-txt/) extension, go to youtube.com while logged in, click the extension β 'Current Site' to export.*", elem_classes=["hint-text"])
|
| 1793 |
+
|
| 1794 |
+
with gr.Tab("π Import Deck", id="tab_import") as tab_import:
|
| 1795 |
+
gr.Markdown("### Load a saved deck into the app")
|
| 1796 |
+
gr.Markdown("Upload a previously exported **JSON file** or an **Anki .apkg deck** to reload flashcards without regenerating.")
|
| 1797 |
+
import_json_file_in = gr.File(label="π JSON Deck (.json)", file_types=[".json"])
|
| 1798 |
+
import_anki_file_in = gr.File(label="π¦ Anki Deck (.apkg)", file_types=[".apkg"])
|
| 1799 |
+
import_load_btn = gr.Button("π Load Deck", variant="primary")
|
| 1800 |
+
|
| 1801 |
# Track active tab
|
| 1802 |
tab_url.select(fn=lambda: "Website URL", inputs=None, outputs=active_tab)
|
| 1803 |
tab_pdf.select(fn=lambda: "Upload PDF", inputs=None, outputs=active_tab)
|
| 1804 |
tab_audio.select(fn=lambda: "Upload Audio", inputs=None, outputs=active_tab)
|
| 1805 |
tab_yt.select(fn=lambda: "YouTube Link", inputs=None, outputs=active_tab)
|
| 1806 |
+
tab_import.select(fn=lambda: "Import Deck", inputs=None, outputs=active_tab)
|
| 1807 |
+
|
| 1808 |
gr.Markdown("### βοΈ Customization Settings")
|
| 1809 |
max_text_char_input = gr.Slider(minimum=1000, maximum=30000, step=1000, value=1500, label="Max Input Text Length (Characters)")
|
| 1810 |
repetition_penalty_input = gr.Slider(minimum=0.1, maximum=2.0, step=0.1, value=1.2, label="Repetition Penalty")
|
| 1811 |
auto_force_chars_input = gr.Slider(minimum=1_000, maximum=10_000, step=100, value=4_000, label="Auto-force JSON after (chars of thinking)")
|
| 1812 |
+
|
| 1813 |
with gr.Accordion("π§ Advanced", open=False):
|
| 1814 |
translit_lang = gr.Dropdown(
|
| 1815 |
+
label="Word Transliteration Language",
|
| 1816 |
+
choices=LANGUAGE_CHOICES,
|
| 1817 |
value="Indo-European - English"
|
| 1818 |
)
|
| 1819 |
translit_format = gr.Dropdown(label="Transliteration Format", choices=["dashed syllable", "regular word with space"], value="dashed syllable")
|
| 1820 |
target_lang = gr.Dropdown(
|
| 1821 |
+
label="Target Language (Full App)",
|
| 1822 |
+
choices=LANGUAGE_CHOICES,
|
| 1823 |
value="Indo-European - English"
|
| 1824 |
)
|
| 1825 |
+
|
| 1826 |
with gr.Row():
|
| 1827 |
submit_btn = gr.Button("β¨ Generate Flashcards β¨", variant="primary")
|
| 1828 |
stop_thinking_btn = gr.Button("β‘ Stop thinking, Generate now", variant="secondary")
|
| 1829 |
stop_btn = gr.Button("π Stop Generation", variant="stop")
|
| 1830 |
+
|
| 1831 |
with gr.Column(scale=2):
|
| 1832 |
+
with gr.Tabs() as output_tabs:
|
| 1833 |
+
with gr.Tab("π Flashcards"):
|
| 1834 |
+
output_html = gr.HTML(label="Flashcards will appear here")
|
| 1835 |
+
gr.Markdown("**Export current deck:**")
|
| 1836 |
+
with gr.Row():
|
| 1837 |
+
export_json_btn = gr.Button("π₯ Export JSON", variant="secondary", size="sm")
|
| 1838 |
+
export_anki_btn = gr.Button("π¦ Export Anki (.apkg)", variant="secondary", size="sm")
|
| 1839 |
+
export_json_out = gr.File(label="β¬οΈ JSON Download", visible=False, interactive=False)
|
| 1840 |
+
export_anki_out = gr.File(label="β¬οΈ Anki Deck Download", visible=False, interactive=False)
|
| 1841 |
+
|
| 1842 |
+
with gr.Tab("β Quiz"):
|
| 1843 |
+
gr.Markdown("**Test your knowledge** with a randomized 5-question multiple-choice quiz from the current deck.")
|
| 1844 |
+
start_quiz_btn = gr.Button("π§ͺ Start 5-Question Quiz", variant="primary")
|
| 1845 |
+
quiz_output_html = gr.HTML(label="Quiz")
|
| 1846 |
+
|
| 1847 |
stream_box = gr.Textbox(label="Live Model Generation π§ ", lines=10, max_lines=20, interactive=False, autoscroll=True, elem_id="stream_box")
|
| 1848 |
+
|
| 1849 |
with gr.Accordion("π Extracted Source Content", open=True):
|
| 1850 |
extracted_text_box = gr.Textbox(label="Extracted Text", lines=10, max_lines=15, interactive=False)
|
| 1851 |
extracted_images_gallery = gr.Gallery(label="Extracted Images", columns=4, height="auto", object_fit="contain")
|
| 1852 |
extracted_audio_player = gr.Audio(label="Extracted Audio (YouTube / Uploaded)", type="filepath", interactive=False)
|
| 1853 |
+
|
| 1854 |
last_source_state = gr.State(None)
|
| 1855 |
last_korean_words_state = gr.State(None)
|
| 1856 |
|
|
|
|
| 1860 |
submit_btn.click(fn=reset_btn_text, inputs=None, outputs=[stop_thinking_btn, stop_btn], queue=False)
|
| 1861 |
|
| 1862 |
generate_event = submit_btn.click(
|
| 1863 |
+
fn=process_pdf,
|
| 1864 |
+
inputs=[pdf_input, url_input, audio_file_input, yt_url_input, yt_cookies_input, translit_lang, translit_format, target_lang, max_text_char_input, repetition_penalty_input, auto_force_chars_input, last_source_state, last_korean_words_state, active_tab],
|
| 1865 |
outputs=[output_html, last_source_state, last_korean_words_state, stream_box, extracted_text_box, extracted_images_gallery, extracted_audio_player]
|
| 1866 |
)
|
| 1867 |
+
|
| 1868 |
+
stop_thinking_btn.click(fn=set_stop_thinking, inputs=None, outputs=stop_thinking_btn, queue=False).then(
|
| 1869 |
+
fn=reset_stop_thinking_after_delay, inputs=None, outputs=stop_thinking_btn
|
| 1870 |
+
)
|
| 1871 |
stop_btn.click(fn=set_kill_threads, inputs=None, outputs=stop_btn, queue=False)
|
| 1872 |
+
|
| 1873 |
+
# Export events
|
| 1874 |
+
export_json_btn.click(fn=export_json_file_fn, inputs=[last_korean_words_state], outputs=[export_json_out])
|
| 1875 |
+
export_anki_btn.click(fn=export_anki_file_fn, inputs=[last_korean_words_state], outputs=[export_anki_out])
|
| 1876 |
+
|
| 1877 |
+
# Import event
|
| 1878 |
+
import_load_btn.click(
|
| 1879 |
+
fn=import_deck_fn,
|
| 1880 |
+
inputs=[import_json_file_in, import_anki_file_in],
|
| 1881 |
+
outputs=[output_html, last_korean_words_state]
|
| 1882 |
+
)
|
| 1883 |
+
|
| 1884 |
+
# Quiz event
|
| 1885 |
+
start_quiz_btn.click(fn=build_quiz_html, inputs=[last_korean_words_state], outputs=[quiz_output_html])
|
| 1886 |
+
|
| 1887 |
# Force autoscroll using Custom JS
|
| 1888 |
stream_box.change(
|
| 1889 |
fn=None,
|