mastefan commited on
Commit
19d1b80
Β·
verified Β·
1 Parent(s): 3b59c7f

Update src/app/main_app.py

Browse files
Files changed (1) hide show
  1. src/app/main_app.py +167 -150
src/app/main_app.py CHANGED
@@ -1,11 +1,6 @@
1
-
2
  ###############################################################
3
  # main_app.py β€” Agentic Language Partner UI (Streamlit)
4
  ###############################################################
5
-
6
- import streamlit as st # MUST BE FIRST
7
- from streamlit_audiorecorder import audiorecorder # <-- ADD THIS
8
-
9
  import pandas as pd
10
  import json
11
  import random
@@ -14,18 +9,25 @@ from datetime import datetime
14
  from pathlib import Path
15
  from typing import Dict, List, Any
16
 
 
 
 
17
  from deep_translator import GoogleTranslator
18
 
19
- # Local imports
20
- from src.app.auth import (
21
  authenticate_user,
22
  register_user,
23
  get_user_prefs,
24
  update_user_prefs,
25
  )
26
- from src.app.config import get_user_dir
27
- from src.app.conversation_core import ConversationManager
28
- from src.app.flashcards_tools import (
 
 
 
 
 
29
  list_user_decks,
30
  load_deck,
31
  _get_decks_dir,
@@ -33,9 +35,8 @@ from src.app.flashcards_tools import (
33
  generate_flashcards_from_text,
34
  generate_flashcards_from_ocr_results,
35
  )
36
- from src.app.ocr_tools import ocr_and_translate_batch
37
- from src.app.viewers import generate_flashcard_viewer_for_user
38
-
39
 
40
 
41
  ###############################################################
@@ -51,14 +52,10 @@ st.set_page_config(
51
  st.markdown(
52
  """
53
  <style>
54
-
55
  .chat-column {
56
  display: flex;
57
  flex-direction: column;
58
  }
59
-
60
-
61
-
62
  /* Input bar at the top */
63
  .chat-input-bar {
64
  margin-bottom: 0.5rem;
@@ -67,7 +64,6 @@ st.markdown(
67
  border: 1px solid #333;
68
  border-radius: 0.5rem;
69
  }
70
-
71
  /* Scrollable chat messages below input */
72
  .chat-window {
73
  max-height: 65vh;
@@ -75,11 +71,9 @@ st.markdown(
75
  padding-right: .75rem;
76
  padding-bottom: 0.5rem;
77
  }
78
-
79
  /* Chat bubbles */
80
  .chat-row-user { justify-content:flex-end; display:flex; margin-bottom:.4rem; }
81
  .chat-row-assistant { justify-content:flex-start; display:flex; margin-bottom:.4rem; }
82
-
83
  .chat-bubble {
84
  border-radius:14px;
85
  padding:.55rem .95rem;
@@ -90,26 +84,21 @@ st.markdown(
90
  }
91
  .chat-bubble-user { background:#3a3b3c; color:white; }
92
  .chat-bubble-assistant { background:#1a73e8; color:white; }
93
-
94
  .chat-aux {
95
  font-size:1.0rem; /* larger translation/explanation */
96
  color:#ccc;
97
  margin:0.1rem 0.25rem 0.5rem 0.25rem;
98
  }
99
-
100
  /* Lock viewport height and avoid infinite page scrolling */
101
  html, body {
102
  height: 100%;
103
  overflow: hidden !important;
104
  }
105
-
106
  .block-container {
107
  height: 100vh !important;
108
  overflow-y: auto !important;
109
  }
110
-
111
  .saved-conv-panel { max-width: 360px; }
112
-
113
  </style>
114
  """,
115
  unsafe_allow_html=True,
@@ -124,6 +113,10 @@ html, body {
124
  # ------------------------------------------------------------
125
 
126
  def preload_models():
 
 
 
 
127
  if st.session_state.get("models_loaded"):
128
  return
129
 
@@ -136,17 +129,14 @@ def preload_models():
136
  load_partner_lm()
137
  except Exception:
138
  pass
139
- # If you don't have NLLB yet, comment this out
140
- # try:
141
- # load_nllb()
142
- # except Exception:
143
- # pass
144
 
145
  st.session_state["models_loaded"] = True
146
 
147
 
148
-
149
-
150
  def get_conv_manager() -> ConversationManager:
151
  if "conv_manager" not in st.session_state:
152
  prefs = st.session_state["prefs"]
@@ -229,7 +219,6 @@ def save_current_conversation(username: str, name: str) -> Path:
229
  return path
230
 
231
 
232
-
233
  ###############################################################
234
  # CHAT HANDLING
235
  ###############################################################
@@ -411,7 +400,6 @@ def dashboard_tab(username: str):
411
  ###########################################################
412
  # MICROPHONE & TRANSCRIPTION CALIBRATION (native phrase)
413
  ###########################################################
414
- # ---- Microphone & transcription calibration (restored original version) ----
415
  st.subheader("Microphone & Transcription Calibration")
416
 
417
  st.write(
@@ -467,9 +455,6 @@ def dashboard_tab(username: str):
467
 
468
  st.markdown("---")
469
 
470
-
471
-
472
-
473
  ###########################################################
474
  # TOOL OVERVIEW
475
  ###########################################################
@@ -479,18 +464,18 @@ def dashboard_tab(username: str):
479
  with c1:
480
  st.markdown("### πŸŽ™οΈ Conversation Partner")
481
  st.write("Real-time language practice with microphone support.")
482
-
483
  with c2:
484
  st.markdown("### πŸƒ Flashcards & Quizzes")
485
  st.write("Starter decks: Alphabet, Numbers, Greetings.")
486
-
487
  with c3:
488
  st.markdown("### πŸ“· OCR Helper")
489
  st.write("Upload images to extract and translate text.")
490
 
491
- # ------------------------------------------------------------
492
- # Settings tab (restore missing function)
493
- # ------------------------------------------------------------
 
 
494
  def settings_tab(username: str):
495
  """Minimal settings tab so main() can call it safely."""
496
  st.header("Settings")
@@ -510,15 +495,14 @@ def settings_tab(username: str):
510
  )
511
 
512
 
513
-
514
  ###############################################################
515
  # CONVERSATION TAB
516
  ###############################################################
517
 
518
  def conversation_tab(username: str):
519
- import re
520
- from datetime import datetime
521
- from deep_translator import GoogleTranslator
522
 
523
  st.header("Conversation")
524
 
@@ -542,7 +526,7 @@ def conversation_tab(username: str):
542
  show_exp = prefs.get("show_explanations", True)
543
 
544
  # ------------------------------------------
545
- # RESET BUTTON (ONLY ONE)
546
  # ------------------------------------------
547
  if st.button("πŸ”„ Reset Conversation"):
548
  st.session_state["chat_history"] = []
@@ -551,7 +535,6 @@ def conversation_tab(username: str):
551
  st.session_state["recorder_key"] += 1
552
  st.experimental_rerun()
553
 
554
-
555
  # ------------------------------------------
556
  # FIRST MESSAGE GREETING
557
  # ------------------------------------------
@@ -563,39 +546,38 @@ def conversation_tab(username: str):
563
  "english": "Hello! I heard you want to practice with me. How is your day going?",
564
  "german": "Hallo! Ich habe gehΓΆrt, dass du ΓΌben mΓΆchtest. Wie geht dein Tag bisher?",
565
  "spanish": "Β‘Hola! EscuchΓ© que querΓ­as practicar conmigo. ΒΏCΓ³mo va tu dΓ­a?",
566
- "japanese":"γ“γ‚“γ«γ‘γ―οΌη·΄ηΏ’γ—γŸγ„γ¨θžγγΎγ—γŸγ€‚δ»Šζ—₯はどんγͺδΈ€ζ—₯γ§γ™γ‹οΌŸ",
567
  }
568
 
569
  intro = default_greetings.get(lang, default_greetings["english"])
570
 
571
  if topic and topic.lower() != "general conversation":
572
  try:
573
- intro = GoogleTranslator(source="en", target=lang).translate(
574
  f"Hello! Let's talk about {topic}. What do you think about it?"
575
  )
576
  except Exception:
577
  pass
578
 
579
  st.session_state["chat_history"].append(
580
- {"role":"assistant","text":intro,"audio":None,"explanation":None}
581
  )
582
 
583
  # ------------------------------------------
584
  # LAYOUT
585
  # ------------------------------------------
586
- col_chat, col_saved = st.columns([3,1])
587
 
588
  # ===========================
589
  # LEFT: CHAT WINDOW
590
  # ===========================
591
  with col_chat:
592
-
593
  st.markdown('<div class="chat-window">', unsafe_allow_html=True)
594
 
595
  for msg in st.session_state["chat_history"]:
596
  role = msg["role"]
597
  bubble = "chat-bubble-user" if role == "user" else "chat-bubble-assistant"
598
- row = "chat-row-user" if role == "user" else "chat-row-assistant"
599
 
600
  st.markdown(
601
  f'<div class="{row}"><div class="chat-bubble {bubble}">{msg["text"]}</div></div>',
@@ -607,39 +589,49 @@ def conversation_tab(username: str):
607
 
608
  if role == "assistant":
609
  try:
610
- tr = GoogleTranslator(source="auto", target=conv.native_language).translate(msg["text"])
611
  st.markdown(f'<div class="chat-aux">{tr}</div>', unsafe_allow_html=True)
612
- except: pass
 
613
 
614
  if show_exp and msg.get("explanation"):
615
  exp = msg["explanation"]
616
 
617
  # Force EXACTLY ONE sentence
618
- exp = re.split(r"(?<=[.!?])\s+", exp)[0].strip()
619
-
620
- # Remove any meta nonsense ("version:", "meaning:", "this sentence", etc)
621
- exp = re.sub(r"(?i)(english version|the meaning|this sentence|the german sentence).*", "", exp).strip()
 
 
 
622
 
623
  if exp:
624
  st.markdown(f'<div class="chat-aux">{exp}</div>', unsafe_allow_html=True)
625
 
626
  # scroll
627
- st.markdown("""
 
628
  <script>
629
  setTimeout(() => {
630
  let w = window.parent.document.getElementsByClassName('chat-window')[0];
631
  if (w) w.scrollTop = w.scrollHeight;
632
  }, 200);
633
  </script>
634
- """, unsafe_allow_html=True)
 
 
635
 
636
  # -------------------------------
637
  # AUDIO RECORDER
638
  # -------------------------------
639
  st.markdown('<div class="sticky-input-bar">', unsafe_allow_html=True)
640
 
641
- audio = audiorecorder("🎀 Speak", "⏹ Stop",
642
- key=f"chat_audio_{st.session_state['recorder_key']}")
 
 
 
643
 
644
  # ------------------------------------------
645
  # STATE: idle β†’ record β†’ transcribe
@@ -659,24 +651,20 @@ def conversation_tab(username: str):
659
  # STATE: pending_speech β†’ confirm
660
  # ------------------------------------------
661
  if st.session_state["speech_state"] == "pending_speech":
662
-
663
  st.write("### Confirm your spoken message:")
664
  st.info(st.session_state["pending_transcript"])
665
 
666
- c1, c2 = st.columns([1,1])
667
  with c1:
668
  if st.button("Send message", key="send_pending"):
669
  txt = st.session_state["pending_transcript"]
670
-
671
  with st.spinner("Partner is responding…"):
672
  handle_user_message(username, txt)
673
 
674
- # cleanup
675
  st.session_state["speech_state"] = "idle"
676
  st.session_state["pending_transcript"] = ""
677
  st.session_state["recorder_key"] += 1
678
  st.experimental_rerun()
679
-
680
  with c2:
681
  if st.button("Discard", key="discard_pending"):
682
  st.session_state["speech_state"] = "idle"
@@ -695,54 +683,55 @@ def conversation_tab(username: str):
695
 
696
  st.markdown("</div>", unsafe_allow_html=True)
697
 
698
- # ======================================================
699
- # RIGHT: SAVED CONVERSATIONS (RESTORED)
700
- # ======================================================
701
- with col_saved:
702
- from pathlib import Path
703
- import json
704
-
705
- st.markdown("### Saved Conversations")
706
-
707
- default_name = datetime.utcnow().strftime("Session %Y-%m-%d %H:%M")
708
- name_box = st.text_input("Name conversation", value=default_name)
709
 
710
- if st.button("Save conversation"):
711
- if not st.session_state["chat_history"]:
712
- st.warning("Nothing to save.")
713
- else:
714
- safe = re.sub(r"[^0-9A-Za-z_-]", "_", name_box)
715
 
716
- path = save_current_conversation(username, safe)
717
- st.success(f"Saved as {path.name}")
 
 
 
 
 
718
 
719
- saved_dir = get_user_dir(username) / "chats" / "saved"
720
- saved_dir.mkdir(parents=True, exist_ok=True)
721
 
722
- files = sorted(saved_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
723
 
724
- for f in files:
725
- data = json.loads(f.read_text())
726
- sess_name = data.get("name", f.stem)
727
- msgs = data.get("messages", [])
728
 
729
- with st.expander(f"{sess_name} ({len(msgs)} msgs)"):
730
- deck_name = st.text_input(f"Deck name for {sess_name}", value=f"deck_{f.stem}")
 
 
 
 
731
 
732
- if st.button(f"Export {f.stem}", key=f"export_{f.stem}"):
733
- body = "\n".join(m["text"] for m in msgs if m["role"] == "assistant")
734
- deck_path = generate_flashcards_from_text(
735
- username=username,
736
- text=body,
737
- deck_name=deck_name,
738
- target_lang=prefs["native_language"],
739
- tags=["conversation"],
740
- )
741
- st.success(f"Deck exported: {deck_path.name}")
 
 
 
 
742
 
743
- if st.button(f"Delete {f.stem}", key=f"delete_{f.stem}"):
744
- f.unlink()
745
- st.experimental_rerun()
746
 
747
  ###############################################################
748
  # OCR TAB
@@ -761,7 +750,10 @@ def ocr_tab(username: str):
761
  return
762
 
763
  with st.spinner("Running OCR…"):
764
- results = ocr_and_translate_batch([f.read() for f in imgs], target_lang=tgt)
 
 
 
765
 
766
  deck_path = generate_flashcards_from_ocr_results(
767
  username=username,
@@ -773,13 +765,30 @@ def ocr_tab(username: str):
773
  st.success(f"Deck saved: {deck_path}")
774
 
775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  ###############################################################
777
  # FLASHCARDS TAB
778
  ###############################################################
779
 
780
  def flashcards_tab(username: str):
781
- import pandas as pd
782
- import re
783
 
784
  # ---------------------------------------------------------
785
  # Helpers
@@ -788,7 +797,7 @@ def flashcards_tab(username: str):
788
  def normalize(s: str) -> str:
789
  """lowercase + strip non-alphanumerics for loose grading."""
790
  s = s.lower()
791
- s = re.sub(r"[^a-z0-9]+", "", s)
792
  return s
793
 
794
  def card_front_html(text: str) -> str:
@@ -824,7 +833,6 @@ def flashcards_tab(username: str):
824
  margin-right:auto;
825
  box-shadow:0 4px 12px rgba(0,0,0,0.25);
826
  ">{front}</div>
827
-
828
  <div style="
829
  background:#2b2b2b;
830
  color:#f5f5f5;
@@ -948,7 +956,7 @@ def flashcards_tab(username: str):
948
  ss[key + "show_back"] = False
949
  st.experimental_rerun()
950
 
951
- # DIFFICULTY GRADING (centered)
952
  st.markdown("### Rate this card")
953
  cA, cB, cC, cD, cE = st.columns(5)
954
 
@@ -961,19 +969,19 @@ def flashcards_tab(username: str):
961
  st.experimental_rerun()
962
 
963
  with cA:
964
- if st.button("πŸ”₯ Very Difficult", key=key+"g_vd"):
965
  apply_grade(-2)
966
  with cB:
967
- if st.button("😣 Hard", key=key+"g_h"):
968
  apply_grade(-1)
969
  with cC:
970
- if st.button("😐 Neutral", key=key+"g_n"):
971
  apply_grade(0)
972
  with cD:
973
- if st.button("πŸ™‚ Easy", key=key+"g_e"):
974
  apply_grade(1)
975
  with cE:
976
- if st.button("πŸ† Mastered", key=key+"g_m"):
977
  apply_grade(3)
978
 
979
  # ---------------------------------------------------
@@ -983,9 +991,15 @@ def flashcards_tab(username: str):
983
  # Initial test setup
984
  if not ss[key + "test_active"]:
985
  st.markdown("### Test Setup")
986
- num_q = st.slider("Number of questions", 3, min(20, len(cards)), min(5, len(cards)), key=key+"nq")
 
 
 
 
 
 
987
 
988
- if st.button("Start Test", key=key+"begin"):
989
  order = list(range(len(cards)))
990
  random.shuffle(order)
991
  order = order[:num_q]
@@ -995,7 +1009,6 @@ def flashcards_tab(username: str):
995
  ss[key + "test_pos"] = 0
996
  ss[key + "test_results"] = []
997
  st.experimental_rerun()
998
-
999
  else:
1000
  order = ss[key + "test_order"]
1001
  pos = ss[key + "test_pos"]
@@ -1004,14 +1017,20 @@ def flashcards_tab(username: str):
1004
  # Test Complete
1005
  if pos >= len(order):
1006
  correct = sum(r["correct"] for r in results)
1007
- st.markdown(f"### Test Complete β€” Score: {correct}/{len(results)} ({correct/len(results)*100:.1f}%)")
 
 
 
1008
  st.markdown("---")
1009
 
1010
  for i, r in enumerate(results, 1):
1011
  emoji = "βœ…" if r["correct"] else "❌"
1012
- st.write(f"**{i}.** {r['front']} β†’ expected **{r['back']}**, you answered *{r['user_answer']}* {emoji}")
 
 
 
1013
 
1014
- if st.button("Restart Test", key=key+"restart"):
1015
  ss[key + "test_active"] = False
1016
  ss[key + "test_pos"] = 0
1017
  ss[key + "test_results"] = []
@@ -1029,54 +1048,55 @@ def flashcards_tab(username: str):
1029
  st.markdown(card_front_html(card["front"]), unsafe_allow_html=True)
1030
 
1031
  # TTS
1032
- if st.button("πŸ”Š Pronounce", key=key+f"tts_test_{pos}"):
1033
  audio = conv.text_to_speech(card["front"])
1034
  if audio:
1035
  st.audio(audio, format="audio/mp3")
1036
 
1037
- user_answer = st.text_input("Your answer:", key=key+f"ans_{pos}")
1038
 
1039
- if st.button("Submit Answer", key=key+f"submit_{pos}"):
1040
  ua = user_answer.strip()
1041
  correct = normalize(ua) == normalize(card["back"])
1042
 
1043
- # Flash feedback
1044
  if correct:
1045
  st.success("Correct!")
1046
  else:
1047
  st.error(f"Incorrect β€” expected: {card['back']}")
1048
 
1049
- results.append({
1050
- "front": card["front"],
1051
- "back": card["back"],
1052
- "user_answer": ua,
1053
- "correct": correct,
1054
- })
 
 
1055
  ss[key + "test_results"] = results
1056
  ss[key + "test_pos"] = pos + 1
1057
  st.experimental_rerun()
1058
 
1059
  # =======================================================
1060
- # DECK AT A GLANCE (FULL WIDTH)
1061
  # =======================================================
1062
  st.markdown("---")
1063
  st.subheader("Deck at a glance")
1064
 
1065
  df_rows = []
1066
  for i, c in enumerate(cards, start=1):
1067
- df_rows.append({
1068
- "#": i,
1069
- "Front": c.get("front", ""),
1070
- "Back": c.get("back", ""),
1071
- "Score": c.get("score", 0),
1072
- "Reviews": c.get("reviews", 0),
1073
- })
 
 
1074
 
1075
  st.dataframe(pd.DataFrame(df_rows), height=500, use_container_width=True)
1076
 
1077
 
1078
-
1079
-
1080
  ###############################################################
1081
  # QUIZ TAB
1082
  ###############################################################
@@ -1201,7 +1221,7 @@ def main():
1201
 
1202
  tab_dash, tab_conv, tab_ocr, tab_flash, tab_quiz, tab_settings = tabs
1203
 
1204
- # restore active tab (required so Streamlit shows correct tab on rerun)
1205
  _ = tabs[st.session_state["active_tab"]]
1206
 
1207
  with tab_dash:
@@ -1229,8 +1249,5 @@ def main():
1229
  settings_tab(username)
1230
 
1231
 
1232
-
1233
  if __name__ == "__main__":
1234
  main()
1235
-
1236
-
 
 
1
  ###############################################################
2
  # main_app.py β€” Agentic Language Partner UI (Streamlit)
3
  ###############################################################
 
 
 
 
4
  import pandas as pd
5
  import json
6
  import random
 
9
  from pathlib import Path
10
  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,
19
  register_user,
20
  get_user_prefs,
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,
33
  _get_decks_dir,
 
35
  generate_flashcards_from_text,
36
  generate_flashcards_from_ocr_results,
37
  )
38
+ from .ocr_tools import ocr_and_translate_batch
39
+ from .viewers import generate_flashcard_viewer_for_user
 
40
 
41
 
42
  ###############################################################
 
52
  st.markdown(
53
  """
54
  <style>
 
55
  .chat-column {
56
  display: flex;
57
  flex-direction: column;
58
  }
 
 
 
59
  /* Input bar at the top */
60
  .chat-input-bar {
61
  margin-bottom: 0.5rem;
 
64
  border: 1px solid #333;
65
  border-radius: 0.5rem;
66
  }
 
67
  /* Scrollable chat messages below input */
68
  .chat-window {
69
  max-height: 65vh;
 
71
  padding-right: .75rem;
72
  padding-bottom: 0.5rem;
73
  }
 
74
  /* Chat bubbles */
75
  .chat-row-user { justify-content:flex-end; display:flex; margin-bottom:.4rem; }
76
  .chat-row-assistant { justify-content:flex-start; display:flex; margin-bottom:.4rem; }
 
77
  .chat-bubble {
78
  border-radius:14px;
79
  padding:.55rem .95rem;
 
84
  }
85
  .chat-bubble-user { background:#3a3b3c; color:white; }
86
  .chat-bubble-assistant { background:#1a73e8; color:white; }
 
87
  .chat-aux {
88
  font-size:1.0rem; /* larger translation/explanation */
89
  color:#ccc;
90
  margin:0.1rem 0.25rem 0.5rem 0.25rem;
91
  }
 
92
  /* Lock viewport height and avoid infinite page scrolling */
93
  html, body {
94
  height: 100%;
95
  overflow: hidden !important;
96
  }
 
97
  .block-container {
98
  height: 100vh !important;
99
  overflow-y: auto !important;
100
  }
 
101
  .saved-conv-panel { max-width: 360px; }
 
102
  </style>
103
  """,
104
  unsafe_allow_html=True,
 
113
  # ------------------------------------------------------------
114
 
115
  def preload_models():
116
+ """
117
+ Preloads Whisper, Qwen, and NLLB models only one time.
118
+ This prevents UI freeze or repeated loading during conversation.
119
+ """
120
  if st.session_state.get("models_loaded"):
121
  return
122
 
 
129
  load_partner_lm()
130
  except Exception:
131
  pass
132
+ try:
133
+ load_nllb()
134
+ except Exception:
135
+ pass
 
136
 
137
  st.session_state["models_loaded"] = True
138
 
139
 
 
 
140
  def get_conv_manager() -> ConversationManager:
141
  if "conv_manager" not in st.session_state:
142
  prefs = st.session_state["prefs"]
 
219
  return path
220
 
221
 
 
222
  ###############################################################
223
  # CHAT HANDLING
224
  ###############################################################
 
400
  ###########################################################
401
  # MICROPHONE & TRANSCRIPTION CALIBRATION (native phrase)
402
  ###########################################################
 
403
  st.subheader("Microphone & Transcription Calibration")
404
 
405
  st.write(
 
455
 
456
  st.markdown("---")
457
 
 
 
 
458
  ###########################################################
459
  # TOOL OVERVIEW
460
  ###########################################################
 
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")
 
495
  )
496
 
497
 
 
498
  ###############################################################
499
  # CONVERSATION TAB
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
  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"] = []
 
535
  st.session_state["recorder_key"] += 1
536
  st.experimental_rerun()
537
 
 
538
  # ------------------------------------------
539
  # FIRST MESSAGE GREETING
540
  # ------------------------------------------
 
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
 
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
 
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
 
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
  ###############################################################
737
  # OCR TAB
 
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
  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
  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:
 
833
  margin-right:auto;
834
  box-shadow:0 4px 12px rgba(0,0,0,0.25);
835
  ">{front}</div>
 
836
  <div style="
837
  background:#2b2b2b;
838
  color:#f5f5f5;
 
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
 
 
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
  # 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
  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
  # 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
  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
 
1099
 
 
 
1100
  ###############################################################
1101
  # QUIZ TAB
1102
  ###############################################################
 
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:
 
1249
  settings_tab(username)
1250
 
1251
 
 
1252
  if __name__ == "__main__":
1253
  main()