mastefan commited on
Commit
55d033f
Β·
verified Β·
1 Parent(s): 5a0ce7f

Update src/app/main_app.py

Browse files
Files changed (1) hide show
  1. src/app/main_app.py +151 -169
src/app/main_app.py CHANGED
@@ -11,8 +11,9 @@ from typing import Dict, List, Any
11
 
12
  import streamlit as st
13
  import streamlit.components.v1 as components
14
- from streamlit_audiorecorder import audiorecorder
15
  from deep_translator import GoogleTranslator
 
 
16
 
17
  from .auth import (
18
  authenticate_user,
@@ -21,12 +22,7 @@ from .auth import (
21
  update_user_prefs,
22
  )
23
  from .config import get_user_dir
24
- from .conversation_core import (
25
- ConversationManager,
26
- load_whisper,
27
- load_partner_lm,
28
- load_nllb,
29
- )
30
  from .flashcards_tools import (
31
  list_user_decks,
32
  load_deck,
@@ -120,6 +116,8 @@ def preload_models():
120
  if st.session_state.get("models_loaded"):
121
  return
122
 
 
 
123
  with st.spinner("Loading language & speech models (one-time)…"):
124
  try:
125
  load_whisper()
@@ -406,28 +404,30 @@ def dashboard_tab(username: str):
406
  "To verify that audio recording and transcription are working, "
407
  "please repeat this phrase in your native language:\n\n"
408
  "> \"Hello, my name is [Your Name], and I am here to practice languages.\"\n\n"
409
- "Record a short clip and then run transcription to check accuracy."
410
  )
411
 
412
  calib_col1, calib_col2 = st.columns([2, 1])
413
 
414
  with calib_col1:
415
- calib_audio = audiorecorder(
416
- "🎀 Start calibration",
417
- "⏹ Stop",
418
  key="calibration_audio",
419
  )
420
 
421
- if len(calib_audio) > 0:
422
- st.caption("Calibration audio recorded. Click 'Transcribe sample' to test.")
423
 
424
  if st.button("Transcribe sample", key="calibration_transcribe"):
425
- if len(calib_audio) == 0:
426
- st.warning("Please record a short calibration clip first.")
427
  else:
428
  conv = get_conv_manager()
429
  try:
430
- seg = calib_audio.set_frame_rate(16000).set_channels(1)
 
 
431
  with st.spinner("Transcribing calibration audio…"):
432
  text_out, det_lang, det_prob = conv.transcribe(
433
  seg,
@@ -463,19 +463,20 @@ def dashboard_tab(username: str):
463
  c1, c2, c3 = st.columns(3)
464
  with c1:
465
  st.markdown("### πŸŽ™οΈ Conversation Partner")
466
- st.write("Real-time language practice with microphone support.")
 
467
  with c2:
468
  st.markdown("### πŸƒ Flashcards & Quizzes")
469
  st.write("Starter decks: Alphabet, Numbers, Greetings.")
 
470
  with c3:
471
  st.markdown("### πŸ“· OCR Helper")
472
  st.write("Upload images to extract and translate text.")
473
 
474
 
475
- ###############################################################
476
- # SETTINGS TAB (minimal)
477
- ###############################################################
478
-
479
  def settings_tab(username: str):
480
  """Minimal settings tab so main() can call it safely."""
481
  st.header("Settings")
@@ -500,9 +501,9 @@ def settings_tab(username: str):
500
  ###############################################################
501
 
502
  def conversation_tab(username: str):
503
- import re as _re_local
504
- from datetime import datetime as _dt_local
505
- from deep_translator import GoogleTranslator as _GT
506
 
507
  st.header("Conversation")
508
 
@@ -526,7 +527,7 @@ def conversation_tab(username: str):
526
  show_exp = prefs.get("show_explanations", True)
527
 
528
  # ------------------------------------------
529
- # RESET BUTTON
530
  # ------------------------------------------
531
  if st.button("πŸ”„ Reset Conversation"):
532
  st.session_state["chat_history"] = []
@@ -546,38 +547,39 @@ def conversation_tab(username: str):
546
  "english": "Hello! I heard you want to practice with me. How is your day going?",
547
  "german": "Hallo! Ich habe gehΓΆrt, dass du ΓΌben mΓΆchtest. Wie geht dein Tag bisher?",
548
  "spanish": "Β‘Hola! EscuchΓ© que querΓ­as practicar conmigo. ΒΏCΓ³mo va tu dΓ­a?",
549
- "japanese": "γ“γ‚“γ«γ‘γ―οΌοΏ½οΏ½οΏ½ηΏ’γ—γŸγ„γ¨θžγγΎγ—γŸγ€‚δ»Šζ—₯はどんγͺδΈ€ζ—₯γ§γ™γ‹οΌŸ",
550
  }
551
 
552
  intro = default_greetings.get(lang, default_greetings["english"])
553
 
554
  if topic and topic.lower() != "general conversation":
555
  try:
556
- intro = _GT(source="en", target=lang).translate(
557
  f"Hello! Let's talk about {topic}. What do you think about it?"
558
  )
559
  except Exception:
560
  pass
561
 
562
  st.session_state["chat_history"].append(
563
- {"role": "assistant", "text": intro, "audio": None, "explanation": None}
564
  )
565
 
566
  # ------------------------------------------
567
  # LAYOUT
568
  # ------------------------------------------
569
- col_chat, col_saved = st.columns([3, 1])
570
 
571
  # ===========================
572
  # LEFT: CHAT WINDOW
573
  # ===========================
574
  with col_chat:
 
575
  st.markdown('<div class="chat-window">', unsafe_allow_html=True)
576
 
577
  for msg in st.session_state["chat_history"]:
578
  role = msg["role"]
579
  bubble = "chat-bubble-user" if role == "user" else "chat-bubble-assistant"
580
- row = "chat-row-user" if role == "user" else "chat-row-assistant"
581
 
582
  st.markdown(
583
  f'<div class="{row}"><div class="chat-bubble {bubble}">{msg["text"]}</div></div>',
@@ -589,82 +591,85 @@ def conversation_tab(username: str):
589
 
590
  if role == "assistant":
591
  try:
592
- tr = _GT(source="auto", target=conv.native_language).translate(msg["text"])
593
  st.markdown(f'<div class="chat-aux">{tr}</div>', unsafe_allow_html=True)
594
- except Exception:
595
  pass
596
 
597
  if show_exp and msg.get("explanation"):
598
  exp = msg["explanation"]
599
 
600
  # Force EXACTLY ONE sentence
601
- exp = _re_local.split(r"(?<=[.!?])\s+", exp)[0].strip()
602
- # Trim meta-ish stuff
603
- exp = _re_local.sub(
604
- r"(?i)(english version|the meaning|this sentence|the german sentence).*",
605
- "",
606
- exp,
607
- ).strip()
608
 
609
  if exp:
610
  st.markdown(f'<div class="chat-aux">{exp}</div>', unsafe_allow_html=True)
611
 
612
  # scroll
613
- st.markdown(
614
- """
615
  <script>
616
  setTimeout(() => {
617
  let w = window.parent.document.getElementsByClassName('chat-window')[0];
618
  if (w) w.scrollTop = w.scrollHeight;
619
  }, 200);
620
  </script>
621
- """,
622
- unsafe_allow_html=True,
623
- )
624
 
625
  # -------------------------------
626
- # AUDIO RECORDER
627
  # -------------------------------
628
  st.markdown('<div class="sticky-input-bar">', unsafe_allow_html=True)
629
 
630
- audio = audiorecorder(
631
- "🎀 Speak",
632
- "⏹ Stop",
633
  key=f"chat_audio_{st.session_state['recorder_key']}",
634
  )
635
 
636
  # ------------------------------------------
637
- # STATE: idle β†’ record β†’ transcribe
638
  # ------------------------------------------
639
  if st.session_state["speech_state"] == "idle":
640
- if audio and len(audio) > 0:
641
- seg = audio.set_frame_rate(16000).set_channels(1)
642
- with st.spinner("Transcribing…"):
643
- txt, lang, conf = conv.transcribe(seg, spoken_lang=conv.target_language)
644
-
645
- st.session_state["pending_transcript"] = txt.strip()
646
- st.session_state["speech_state"] = "pending_speech"
647
- st.session_state["recorder_key"] += 1
648
- st.experimental_rerun()
 
 
 
 
 
649
 
650
  # ------------------------------------------
651
  # STATE: pending_speech β†’ confirm
652
  # ------------------------------------------
653
  if st.session_state["speech_state"] == "pending_speech":
 
654
  st.write("### Confirm your spoken message:")
655
  st.info(st.session_state["pending_transcript"])
656
 
657
- c1, c2 = st.columns(2)
658
  with c1:
659
  if st.button("Send message", key="send_pending"):
660
  txt = st.session_state["pending_transcript"]
 
661
  with st.spinner("Partner is responding…"):
662
  handle_user_message(username, txt)
663
 
 
664
  st.session_state["speech_state"] = "idle"
665
  st.session_state["pending_transcript"] = ""
666
  st.session_state["recorder_key"] += 1
667
  st.experimental_rerun()
 
668
  with c2:
669
  if st.button("Discard", key="discard_pending"):
670
  st.session_state["speech_state"] = "idle"
@@ -683,54 +688,54 @@ def conversation_tab(username: str):
683
 
684
  st.markdown("</div>", unsafe_allow_html=True)
685
 
686
- # ===========================
687
- # RIGHT: SAVED CONVERSATIONS
688
- # ===========================
689
- with col_saved:
690
- st.markdown("### Saved Conversations")
 
691
 
692
- default_name = datetime.utcnow().strftime("Session %Y-%m-%d %H:%M")
693
- name_box = st.text_input("Name conversation", value=default_name)
694
 
695
- if st.button("Save conversation"):
696
- if not st.session_state["chat_history"]:
697
- st.warning("Nothing to save.")
698
- else:
699
- safe = re.sub(r"[^0-9A-Za-z_-]", "_", name_box)
700
- path = save_current_conversation(username, safe)
701
- st.success(f"Saved as {path.name}")
702
 
703
- saved_dir = get_user_dir(username) / "chats" / "saved"
704
- saved_dir.mkdir(parents=True, exist_ok=True)
 
 
 
705
 
706
- files = sorted(saved_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
 
707
 
708
- for f in files:
709
- data = json.loads(f.read_text())
710
- sess_name = data.get("name", f.stem)
711
- msgs = data.get("messages", [])
712
 
713
- with st.expander(f"{sess_name} ({len(msgs)} msgs)"):
714
- deck_name = st.text_input(
715
- f"Deck name for {sess_name}",
716
- value=f"deck_{f.stem}",
717
- key=f"deckname_{f.stem}",
718
- )
719
 
720
- if st.button(f"Export {f.stem}", key=f"export_{f.stem}"):
721
- body = "\n".join(m["text"] for m in msgs if m["role"] == "assistant")
722
- deck_path = generate_flashcards_from_text(
723
- username=username,
724
- text=body,
725
- deck_name=deck_name,
726
- target_lang=prefs["native_language"],
727
- tags=["conversation"],
728
- )
729
- st.success(f"Deck exported: {deck_path.name}")
730
-
731
- if st.button(f"Delete {f.stem}", key=f"delete_{f.stem}"):
732
- f.unlink()
733
- st.experimental_rerun()
 
 
 
 
 
 
 
 
734
 
735
 
736
  ###############################################################
@@ -750,10 +755,7 @@ def ocr_tab(username: str):
750
  return
751
 
752
  with st.spinner("Running OCR…"):
753
- results = ocr_and_translate_batch(
754
- [f.read() for f in imgs],
755
- target_lang=tgt,
756
- )
757
 
758
  deck_path = generate_flashcards_from_ocr_results(
759
  username=username,
@@ -765,30 +767,13 @@ def ocr_tab(username: str):
765
  st.success(f"Deck saved: {deck_path}")
766
 
767
 
768
- ###############################################################
769
- # FLASHCARD HELPERS
770
- ###############################################################
771
-
772
- def _choose_next_card_index(cards):
773
- """
774
- Choose the next card index, preferring cards with lower score.
775
- Very simple spaced-repetition style: pick randomly among the lowest-score cards.
776
- """
777
- if not cards:
778
- return 0
779
- scores = [c.get("score", 0) for c in cards]
780
- min_score = min(scores)
781
- candidates = [i for i, s in enumerate(scores) if s == min_score]
782
- return random.choice(candidates)
783
-
784
-
785
  ###############################################################
786
  # FLASHCARDS TAB
787
  ###############################################################
788
 
789
  def flashcards_tab(username: str):
790
- import re as _re_local
791
- import pandas as _pd_local
792
 
793
  # ---------------------------------------------------------
794
  # Helpers
@@ -797,7 +782,7 @@ def flashcards_tab(username: str):
797
  def normalize(s: str) -> str:
798
  """lowercase + strip non-alphanumerics for loose grading."""
799
  s = s.lower()
800
- s = _re_local.sub(r"[^a-z0-9]+", "", s)
801
  return s
802
 
803
  def card_front_html(text: str) -> str:
@@ -956,10 +941,21 @@ def flashcards_tab(username: str):
956
  ss[key + "show_back"] = False
957
  st.experimental_rerun()
958
 
959
- # DIFFICULTY GRADING
960
  st.markdown("### Rate this card")
961
  cA, cB, cC, cD, cE = st.columns(5)
962
 
 
 
 
 
 
 
 
 
 
 
 
963
  def apply_grade(delta):
964
  card["score"] = max(0, card.get("score", 0) + delta)
965
  card["reviews"] = card.get("reviews", 0) + 1
@@ -969,19 +965,19 @@ def flashcards_tab(username: str):
969
  st.experimental_rerun()
970
 
971
  with cA:
972
- if st.button("πŸ”₯ Very Difficult", key=key + "g_vd"):
973
  apply_grade(-2)
974
  with cB:
975
- if st.button("😣 Hard", key=key + "g_h"):
976
  apply_grade(-1)
977
  with cC:
978
- if st.button("😐 Neutral", key=key + "g_n"):
979
  apply_grade(0)
980
  with cD:
981
- if st.button("πŸ™‚ Easy", key=key + "g_e"):
982
  apply_grade(1)
983
  with cE:
984
- if st.button("πŸ† Mastered", key=key + "g_m"):
985
  apply_grade(3)
986
 
987
  # ---------------------------------------------------
@@ -991,15 +987,9 @@ def flashcards_tab(username: str):
991
  # Initial test setup
992
  if not ss[key + "test_active"]:
993
  st.markdown("### Test Setup")
994
- num_q = st.slider(
995
- "Number of questions",
996
- 3,
997
- min(20, len(cards)),
998
- min(5, len(cards)),
999
- key=key + "nq",
1000
- )
1001
 
1002
- if st.button("Start Test", key=key + "begin"):
1003
  order = list(range(len(cards)))
1004
  random.shuffle(order)
1005
  order = order[:num_q]
@@ -1009,6 +999,7 @@ def flashcards_tab(username: str):
1009
  ss[key + "test_pos"] = 0
1010
  ss[key + "test_results"] = []
1011
  st.experimental_rerun()
 
1012
  else:
1013
  order = ss[key + "test_order"]
1014
  pos = ss[key + "test_pos"]
@@ -1017,20 +1008,14 @@ def flashcards_tab(username: str):
1017
  # Test Complete
1018
  if pos >= len(order):
1019
  correct = sum(r["correct"] for r in results)
1020
- st.markdown(
1021
- f"### Test Complete β€” Score: {correct}/{len(results)} "
1022
- f"({correct/len(results)*100:.1f}%)"
1023
- )
1024
  st.markdown("---")
1025
 
1026
  for i, r in enumerate(results, 1):
1027
  emoji = "βœ…" if r["correct"] else "❌"
1028
- st.write(
1029
- f"**{i}.** {r['front']} β†’ expected **{r['back']}**, "
1030
- f"you answered *{r['user_answer']}* {emoji}"
1031
- )
1032
 
1033
- if st.button("Restart Test", key=key + "restart"):
1034
  ss[key + "test_active"] = False
1035
  ss[key + "test_pos"] = 0
1036
  ss[key + "test_results"] = []
@@ -1048,51 +1033,48 @@ def flashcards_tab(username: str):
1048
  st.markdown(card_front_html(card["front"]), unsafe_allow_html=True)
1049
 
1050
  # TTS
1051
- if st.button("πŸ”Š Pronounce", key=key + f"tts_test_{pos}"):
1052
  audio = conv.text_to_speech(card["front"])
1053
  if audio:
1054
  st.audio(audio, format="audio/mp3")
1055
 
1056
- user_answer = st.text_input("Your answer:", key=key + f"ans_{pos}")
1057
 
1058
- if st.button("Submit Answer", key=key + f"submit_{pos}"):
1059
  ua = user_answer.strip()
1060
  correct = normalize(ua) == normalize(card["back"])
1061
 
 
1062
  if correct:
1063
  st.success("Correct!")
1064
  else:
1065
  st.error(f"Incorrect β€” expected: {card['back']}")
1066
 
1067
- results.append(
1068
- {
1069
- "front": card["front"],
1070
- "back": card["back"],
1071
- "user_answer": ua,
1072
- "correct": correct,
1073
- }
1074
- )
1075
  ss[key + "test_results"] = results
1076
  ss[key + "test_pos"] = pos + 1
1077
  st.experimental_rerun()
1078
 
1079
  # =======================================================
1080
- # DECK AT A GLANCE
1081
  # =======================================================
1082
  st.markdown("---")
1083
  st.subheader("Deck at a glance")
1084
 
1085
  df_rows = []
1086
  for i, c in enumerate(cards, start=1):
1087
- df_rows.append(
1088
- {
1089
- "#": i,
1090
- "Front": c.get("front", ""),
1091
- "Back": c.get("back", ""),
1092
- "Score": c.get("score", 0),
1093
- "Reviews": c.get("reviews", 0),
1094
- }
1095
- )
1096
 
1097
  st.dataframe(pd.DataFrame(df_rows), height=500, use_container_width=True)
1098
 
@@ -1221,7 +1203,7 @@ def main():
1221
 
1222
  tab_dash, tab_conv, tab_ocr, tab_flash, tab_quiz, tab_settings = tabs
1223
 
1224
- # restore active tab
1225
  _ = tabs[st.session_state["active_tab"]]
1226
 
1227
  with tab_dash:
 
11
 
12
  import streamlit as st
13
  import streamlit.components.v1 as components
 
14
  from deep_translator import GoogleTranslator
15
+ from pydub import AudioSegment
16
+ from io import BytesIO
17
 
18
  from .auth import (
19
  authenticate_user,
 
22
  update_user_prefs,
23
  )
24
  from .config import get_user_dir
25
+ from .conversation_core import ConversationManager
 
 
 
 
 
26
  from .flashcards_tools import (
27
  list_user_decks,
28
  load_deck,
 
116
  if st.session_state.get("models_loaded"):
117
  return
118
 
119
+ from .conversation_core import load_whisper, load_partner_lm, load_nllb
120
+
121
  with st.spinner("Loading language & speech models (one-time)…"):
122
  try:
123
  load_whisper()
 
404
  "To verify that audio recording and transcription are working, "
405
  "please repeat this phrase in your native language:\n\n"
406
  "> \"Hello, my name is [Your Name], and I am here to practice languages.\"\n\n"
407
+ "Upload or record a short clip and then run transcription to check accuracy."
408
  )
409
 
410
  calib_col1, calib_col2 = st.columns([2, 1])
411
 
412
  with calib_col1:
413
+ calib_file = st.file_uploader(
414
+ "Upload or record a short audio sample (e.g., WAV/MP3)",
415
+ type=["wav", "mp3", "m4a", "ogg"],
416
  key="calibration_audio",
417
  )
418
 
419
+ if calib_file is not None:
420
+ st.caption("Calibration audio loaded. Click 'Transcribe sample' to test.")
421
 
422
  if st.button("Transcribe sample", key="calibration_transcribe"):
423
+ if calib_file is None:
424
+ st.warning("Please upload or record a short calibration clip first.")
425
  else:
426
  conv = get_conv_manager()
427
  try:
428
+ raw = calib_file.read()
429
+ seg = AudioSegment.from_file(BytesIO(raw))
430
+ seg = seg.set_frame_rate(16000).set_channels(1)
431
  with st.spinner("Transcribing calibration audio…"):
432
  text_out, det_lang, det_prob = conv.transcribe(
433
  seg,
 
463
  c1, c2, c3 = st.columns(3)
464
  with c1:
465
  st.markdown("### πŸŽ™οΈ Conversation Partner")
466
+ st.write("Real-time language practice with microphone support (via audio uploads).")
467
+
468
  with c2:
469
  st.markdown("### πŸƒ Flashcards & Quizzes")
470
  st.write("Starter decks: Alphabet, Numbers, Greetings.")
471
+
472
  with c3:
473
  st.markdown("### πŸ“· OCR Helper")
474
  st.write("Upload images to extract and translate text.")
475
 
476
 
477
+ # ------------------------------------------------------------
478
+ # Settings tab (restore missing function)
479
+ # ------------------------------------------------------------
 
480
  def settings_tab(username: str):
481
  """Minimal settings tab so main() can call it safely."""
482
  st.header("Settings")
 
501
  ###############################################################
502
 
503
  def conversation_tab(username: str):
504
+ import re
505
+ from datetime import datetime
506
+ from deep_translator import GoogleTranslator
507
 
508
  st.header("Conversation")
509
 
 
527
  show_exp = prefs.get("show_explanations", True)
528
 
529
  # ------------------------------------------
530
+ # RESET BUTTON (ONLY ONE)
531
  # ------------------------------------------
532
  if st.button("πŸ”„ Reset Conversation"):
533
  st.session_state["chat_history"] = []
 
547
  "english": "Hello! I heard you want to practice with me. How is your day going?",
548
  "german": "Hallo! Ich habe gehΓΆrt, dass du ΓΌben mΓΆchtest. Wie geht dein Tag bisher?",
549
  "spanish": "Β‘Hola! EscuchΓ© que querΓ­as practicar conmigo. ΒΏCΓ³mo va tu dΓ­a?",
550
+ "japanese":"γ“γ‚“γ«γ‘γ―οΌη·΄ηΏ’γ—γŸγ„γ¨θžγγΎγ—γŸγ€‚δ»Šζ—₯はどんγͺδΈ€ζ—₯γ§γ™γ‹οΌŸ",
551
  }
552
 
553
  intro = default_greetings.get(lang, default_greetings["english"])
554
 
555
  if topic and topic.lower() != "general conversation":
556
  try:
557
+ intro = GoogleTranslator(source="en", target=lang).translate(
558
  f"Hello! Let's talk about {topic}. What do you think about it?"
559
  )
560
  except Exception:
561
  pass
562
 
563
  st.session_state["chat_history"].append(
564
+ {"role":"assistant","text":intro,"audio":None,"explanation":None}
565
  )
566
 
567
  # ------------------------------------------
568
  # LAYOUT
569
  # ------------------------------------------
570
+ col_chat, col_saved = st.columns([3,1])
571
 
572
  # ===========================
573
  # LEFT: CHAT WINDOW
574
  # ===========================
575
  with col_chat:
576
+
577
  st.markdown('<div class="chat-window">', unsafe_allow_html=True)
578
 
579
  for msg in st.session_state["chat_history"]:
580
  role = msg["role"]
581
  bubble = "chat-bubble-user" if role == "user" else "chat-bubble-assistant"
582
+ row = "chat-row-user" if role == "user" else "chat-row-assistant"
583
 
584
  st.markdown(
585
  f'<div class="{row}"><div class="chat-bubble {bubble}">{msg["text"]}</div></div>',
 
591
 
592
  if role == "assistant":
593
  try:
594
+ tr = GoogleTranslator(source="auto", target=conv.native_language).translate(msg["text"])
595
  st.markdown(f'<div class="chat-aux">{tr}</div>', unsafe_allow_html=True)
596
+ except:
597
  pass
598
 
599
  if show_exp and msg.get("explanation"):
600
  exp = msg["explanation"]
601
 
602
  # Force EXACTLY ONE sentence
603
+ exp = re.split(r"(?<=[.!?])\s+", exp)[0].strip()
604
+
605
+ # Remove any meta nonsense ("version:", "meaning:", "this sentence", etc)
606
+ exp = re.sub(r"(?i)(english version|the meaning|this sentence|the german sentence).*", "", exp).strip()
 
 
 
607
 
608
  if exp:
609
  st.markdown(f'<div class="chat-aux">{exp}</div>', unsafe_allow_html=True)
610
 
611
  # scroll
612
+ st.markdown("""
 
613
  <script>
614
  setTimeout(() => {
615
  let w = window.parent.document.getElementsByClassName('chat-window')[0];
616
  if (w) w.scrollTop = w.scrollHeight;
617
  }, 200);
618
  </script>
619
+ """, unsafe_allow_html=True)
 
 
620
 
621
  # -------------------------------
622
+ # AUDIO UPLOAD / RECORDING
623
  # -------------------------------
624
  st.markdown('<div class="sticky-input-bar">', unsafe_allow_html=True)
625
 
626
+ audio_file = st.file_uploader(
627
+ "🎀 Upload or record an audio message",
628
+ type=["wav", "mp3", "m4a", "ogg"],
629
  key=f"chat_audio_{st.session_state['recorder_key']}",
630
  )
631
 
632
  # ------------------------------------------
633
+ # STATE: idle β†’ file β†’ transcribe
634
  # ------------------------------------------
635
  if st.session_state["speech_state"] == "idle":
636
+ if audio_file is not None:
637
+ raw = audio_file.read()
638
+ try:
639
+ seg = AudioSegment.from_file(BytesIO(raw))
640
+ seg = seg.set_frame_rate(16000).set_channels(1)
641
+ with st.spinner("Transcribing…"):
642
+ txt, lang, conf = conv.transcribe(seg, spoken_lang=conv.target_language)
643
+
644
+ st.session_state["pending_transcript"] = txt.strip()
645
+ st.session_state["speech_state"] = "pending_speech"
646
+ st.session_state["recorder_key"] += 1
647
+ st.experimental_rerun()
648
+ except Exception as e:
649
+ st.error(f"Audio decode/transcription error: {e}")
650
 
651
  # ------------------------------------------
652
  # STATE: pending_speech β†’ confirm
653
  # ------------------------------------------
654
  if st.session_state["speech_state"] == "pending_speech":
655
+
656
  st.write("### Confirm your spoken message:")
657
  st.info(st.session_state["pending_transcript"])
658
 
659
+ c1, c2 = st.columns([1,1])
660
  with c1:
661
  if st.button("Send message", key="send_pending"):
662
  txt = st.session_state["pending_transcript"]
663
+
664
  with st.spinner("Partner is responding…"):
665
  handle_user_message(username, txt)
666
 
667
+ # cleanup
668
  st.session_state["speech_state"] = "idle"
669
  st.session_state["pending_transcript"] = ""
670
  st.session_state["recorder_key"] += 1
671
  st.experimental_rerun()
672
+
673
  with c2:
674
  if st.button("Discard", key="discard_pending"):
675
  st.session_state["speech_state"] = "idle"
 
688
 
689
  st.markdown("</div>", unsafe_allow_html=True)
690
 
691
+ # ======================================================
692
+ # RIGHT: SAVED CONVERSATIONS
693
+ # ======================================================
694
+ with col_saved:
695
+ from pathlib import Path
696
+ import json
697
 
698
+ st.markdown("### Saved Conversations")
 
699
 
700
+ default_name = datetime.utcnow().strftime("Session %Y-%m-%d %H:%M")
701
+ name_box = st.text_input("Name conversation", value=default_name)
 
 
 
 
 
702
 
703
+ if st.button("Save conversation"):
704
+ if not st.session_state["chat_history"]:
705
+ st.warning("Nothing to save.")
706
+ else:
707
+ safe = re.sub(r"[^0-9A-Za-z_-]", "_", name_box)
708
 
709
+ path = save_current_conversation(username, safe)
710
+ st.success(f"Saved as {path.name}")
711
 
712
+ saved_dir = get_user_dir(username) / "chats" / "saved"
713
+ saved_dir.mkdir(parents=True, exist_ok=True)
 
 
714
 
715
+ files = sorted(saved_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
 
 
 
 
 
716
 
717
+ for f in files:
718
+ data = json.loads(f.read_text())
719
+ sess_name = data.get("name", f.stem)
720
+ msgs = data.get("messages", [])
721
+
722
+ with st.expander(f"{sess_name} ({len(msgs)} msgs)"):
723
+ deck_name = st.text_input(f"Deck name for {sess_name}", value=f"deck_{f.stem}")
724
+
725
+ if st.button(f"Export {f.stem}", key=f"export_{f.stem}"):
726
+ body = "\n".join(m["text"] for m in msgs if m["role"] == "assistant")
727
+ deck_path = generate_flashcards_from_text(
728
+ username=username,
729
+ text=body,
730
+ deck_name=deck_name,
731
+ target_lang=prefs["native_language"],
732
+ tags=["conversation"],
733
+ )
734
+ st.success(f"Deck exported: {deck_path.name}")
735
+
736
+ if st.button(f"Delete {f.stem}", key=f"delete_{f.stem}"):
737
+ f.unlink()
738
+ st.experimental_rerun()
739
 
740
 
741
  ###############################################################
 
755
  return
756
 
757
  with st.spinner("Running OCR…"):
758
+ results = ocr_and_translate_batch([f.read() for f in imgs], target_lang=tgt)
 
 
 
759
 
760
  deck_path = generate_flashcards_from_ocr_results(
761
  username=username,
 
767
  st.success(f"Deck saved: {deck_path}")
768
 
769
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  ###############################################################
771
  # FLASHCARDS TAB
772
  ###############################################################
773
 
774
  def flashcards_tab(username: str):
775
+ import pandas as pd
776
+ import re
777
 
778
  # ---------------------------------------------------------
779
  # Helpers
 
782
  def normalize(s: str) -> str:
783
  """lowercase + strip non-alphanumerics for loose grading."""
784
  s = s.lower()
785
+ s = re.sub(r"[^a-z0-9]+", "", s)
786
  return s
787
 
788
  def card_front_html(text: str) -> str:
 
941
  ss[key + "show_back"] = False
942
  st.experimental_rerun()
943
 
944
+ # DIFFICULTY GRADING (centered)
945
  st.markdown("### Rate this card")
946
  cA, cB, cC, cD, cE = st.columns(5)
947
 
948
+ def _choose_next_card_index(cards_list):
949
+ # Simple heuristic: prefer lower-score / less-reviewed cards
950
+ scored = []
951
+ for i, c in enumerate(cards_list):
952
+ score = c.get("score", 0)
953
+ reviews = c.get("reviews", 0)
954
+ priority = score - 0.3 * reviews
955
+ scored.append((priority, i))
956
+ scored.sort(key=lambda x: x[0])
957
+ return scored[0][1] if scored else 0
958
+
959
  def apply_grade(delta):
960
  card["score"] = max(0, card.get("score", 0) + delta)
961
  card["reviews"] = card.get("reviews", 0) + 1
 
965
  st.experimental_rerun()
966
 
967
  with cA:
968
+ if st.button("πŸ”₯ Very Difficult", key=key+"g_vd"):
969
  apply_grade(-2)
970
  with cB:
971
+ if st.button("😣 Hard", key=key+"g_h"):
972
  apply_grade(-1)
973
  with cC:
974
+ if st.button("😐 Neutral", key=key+"g_n"):
975
  apply_grade(0)
976
  with cD:
977
+ if st.button("πŸ™‚ Easy", key=key+"g_e"):
978
  apply_grade(1)
979
  with cE:
980
+ if st.button("πŸ† Mastered", key=key+"g_m"):
981
  apply_grade(3)
982
 
983
  # ---------------------------------------------------
 
987
  # Initial test setup
988
  if not ss[key + "test_active"]:
989
  st.markdown("### Test Setup")
990
+ num_q = st.slider("Number of questions", 3, min(20, len(cards)), min(5, len(cards)), key=key+"nq")
 
 
 
 
 
 
991
 
992
+ if st.button("Start Test", key=key+"begin"):
993
  order = list(range(len(cards)))
994
  random.shuffle(order)
995
  order = order[:num_q]
 
999
  ss[key + "test_pos"] = 0
1000
  ss[key + "test_results"] = []
1001
  st.experimental_rerun()
1002
+
1003
  else:
1004
  order = ss[key + "test_order"]
1005
  pos = ss[key + "test_pos"]
 
1008
  # Test Complete
1009
  if pos >= len(order):
1010
  correct = sum(r["correct"] for r in results)
1011
+ st.markdown(f"### Test Complete β€” Score: {correct}/{len(results)} ({correct/len(results)*100:.1f}%)")
 
 
 
1012
  st.markdown("---")
1013
 
1014
  for i, r in enumerate(results, 1):
1015
  emoji = "βœ…" if r["correct"] else "❌"
1016
+ st.write(f"**{i}.** {r['front']} β†’ expected **{r['back']}**, you answered *{r['user_answer']}* {emoji}")
 
 
 
1017
 
1018
+ if st.button("Restart Test", key=key+"restart"):
1019
  ss[key + "test_active"] = False
1020
  ss[key + "test_pos"] = 0
1021
  ss[key + "test_results"] = []
 
1033
  st.markdown(card_front_html(card["front"]), unsafe_allow_html=True)
1034
 
1035
  # TTS
1036
+ if st.button("πŸ”Š Pronounce", key=key+f"tts_test_{pos}"):
1037
  audio = conv.text_to_speech(card["front"])
1038
  if audio:
1039
  st.audio(audio, format="audio/mp3")
1040
 
1041
+ user_answer = st.text_input("Your answer:", key=key+f"ans_{pos}")
1042
 
1043
+ if st.button("Submit Answer", key=key+f"submit_{pos}"):
1044
  ua = user_answer.strip()
1045
  correct = normalize(ua) == normalize(card["back"])
1046
 
1047
+ # Flash feedback
1048
  if correct:
1049
  st.success("Correct!")
1050
  else:
1051
  st.error(f"Incorrect β€” expected: {card['back']}")
1052
 
1053
+ results.append({
1054
+ "front": card["front"],
1055
+ "back": card["back"],
1056
+ "user_answer": ua,
1057
+ "correct": correct,
1058
+ })
 
 
1059
  ss[key + "test_results"] = results
1060
  ss[key + "test_pos"] = pos + 1
1061
  st.experimental_rerun()
1062
 
1063
  # =======================================================
1064
+ # DECK AT A GLANCE (FULL WIDTH)
1065
  # =======================================================
1066
  st.markdown("---")
1067
  st.subheader("Deck at a glance")
1068
 
1069
  df_rows = []
1070
  for i, c in enumerate(cards, start=1):
1071
+ df_rows.append({
1072
+ "#": i,
1073
+ "Front": c.get("front", ""),
1074
+ "Back": c.get("back", ""),
1075
+ "Score": c.get("score", 0),
1076
+ "Reviews": c.get("reviews", 0),
1077
+ })
 
 
1078
 
1079
  st.dataframe(pd.DataFrame(df_rows), height=500, use_container_width=True)
1080
 
 
1203
 
1204
  tab_dash, tab_conv, tab_ocr, tab_flash, tab_quiz, tab_settings = tabs
1205
 
1206
+ # restore active tab (required so Streamlit shows correct tab on rerun)
1207
  _ = tabs[st.session_state["active_tab"]]
1208
 
1209
  with tab_dash: