mastefan commited on
Commit
e82864c
·
verified ·
1 Parent(s): 9a8b854

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,12 +1,9 @@
1
- ---
2
- title: Agentic Language Partner
3
- emoji: 🔥
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.0.1
8
- app_file: app.py
9
- pinned: false
10
- ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
+ # Agentic Language Partner
 
 
 
 
 
 
 
 
 
2
 
3
+ Deployed automatically from Google Colab for `mastefan`.
4
+ This space runs a full Streamlit app including:
5
+
6
+ - Audio conversation partner (Qwen + Whisper)
7
+ - OCR → flashcards
8
+ - Flashcard viewer + quizzes
9
+ - User login system (local JSON)
app.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parent
5
+ SRC = ROOT / "src"
6
+ sys.path.append(str(SRC))
7
+
8
+ from app.main_app import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
data/auth/users.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit==1.28.0
2
+ tornado==6.3.3
3
+ deep-translator
4
+ pytesseract
5
+ pillow
6
+ gTTS
7
+ pydub
8
+ soundfile
9
+ transformers==4.40.0
10
+ accelerate
11
+ sentencepiece
12
+ faster-whisper==1.0.3
13
+ ctranslate2==4.5.0
14
+ streamlit-audiorecorder
src/app/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """
3
+ App package initializer for the Agentic Language Partner project.
4
+ Makes core modules importable and organizes app-wide namespaces.
5
+ """
6
+
7
+ __all__ = [
8
+ "auth",
9
+ "config",
10
+ "conversation_core",
11
+ "flashcards_tools",
12
+ "ocr_tools",
13
+ "quiz_tools",
14
+ "viewers",
15
+ ]
src/app/auth.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/app/auth.py
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ from app.config import get_user_dir
10
+
11
+
12
+ def _get_users_json() -> Path:
13
+ """
14
+ Returns: Path to the main users.json file.
15
+ """
16
+ root = Path(__file__).resolve().parents[2]
17
+ auth_dir = root / "data" / "auth"
18
+ auth_dir.mkdir(parents=True, exist_ok=True)
19
+ users_file = auth_dir / "users.json"
20
+
21
+ if not users_file.exists():
22
+ users_file.write_text("{}", encoding="utf-8")
23
+
24
+ return users_file
25
+
26
+
27
+ def _load_users() -> Dict[str, Dict]:
28
+ users_file = _get_users_json()
29
+ try:
30
+ return json.loads(users_file.read_text(encoding="utf-8"))
31
+ except Exception:
32
+ return {}
33
+
34
+
35
+ def _save_users(data: Dict[str, Dict]) -> None:
36
+ users_file = _get_users_json()
37
+ users_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
38
+
39
+
40
+ # ------------------------------------------------------------
41
+ # AUTH FUNCTIONS
42
+ # ------------------------------------------------------------
43
+
44
+ def register_user(username: str, password: str) -> bool:
45
+ if not username or not password:
46
+ return False
47
+
48
+ users = _load_users()
49
+ if username in users:
50
+ return False
51
+
52
+ users[username] = {
53
+ "password": password,
54
+ "prefs": {
55
+ "target_language": "english",
56
+ "native_language": "english",
57
+ "cefr_level": "B1",
58
+ "topic": "general conversation",
59
+ },
60
+ }
61
+ _save_users(users)
62
+
63
+ # create the user folder
64
+ get_user_dir(username)
65
+
66
+ return True
67
+
68
+
69
+ def authenticate_user(username: str, password: str) -> bool:
70
+ users = _load_users()
71
+ entry = users.get(username)
72
+ if not entry:
73
+ return False
74
+ return entry.get("password") == password
75
+
76
+
77
+ def get_user_prefs(username: str) -> Dict:
78
+ users = _load_users()
79
+ entry = users.get(username, {})
80
+ return entry.get("prefs", {})
81
+
82
+
83
+ def update_user_prefs(username: str, prefs: Dict) -> None:
84
+ users = _load_users()
85
+ if username not in users:
86
+ return
87
+ users[username]["prefs"] = prefs
88
+ _save_users(users)
89
+
src/app/config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/app/config.py
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ def get_project_root() -> Path:
8
+ """
9
+ Returns the project root directory.
10
+
11
+ Assumes this file is located at:
12
+ <project_root>/src/app/config.py
13
+ so we go up two parents.
14
+ """
15
+ return Path(__file__).resolve().parents[2]
16
+
17
+
18
+ def get_data_dir() -> Path:
19
+ """
20
+ Root data directory for all persisted user content.
21
+ """
22
+ root = get_project_root()
23
+ data_dir = root / "data"
24
+ data_dir.mkdir(parents=True, exist_ok=True)
25
+ return data_dir
26
+
27
+
28
+ def get_user_dir(username: str) -> Path:
29
+ """
30
+ Returns the directory for a given user and ensures that
31
+ its subfolders exist.
32
+ """
33
+ data_dir = get_data_dir()
34
+ user_dir = data_dir / "users" / username
35
+ user_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ # create standard subfolders
38
+ (user_dir / "decks").mkdir(parents=True, exist_ok=True)
39
+ (user_dir / "viewers").mkdir(parents=True, exist_ok=True)
40
+ (user_dir / "chats").mkdir(parents=True, exist_ok=True)
41
+ (user_dir / "quizzes").mkdir(parents=True, exist_ok=True)
42
+
43
+ return user_dir
44
+
src/app/conversation_core.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ###############################################################
3
+ # conversation_core.py — Agentic Partner Core (Qwen 1.5B + Whisper)
4
+ ###############################################################
5
+
6
+ import io
7
+ import re
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from typing import List, Optional, Tuple
11
+
12
+ import torch
13
+ from gtts import gTTS
14
+ from faster_whisper import WhisperModel
15
+ from transformers import (
16
+ AutoTokenizer,
17
+ AutoModelForCausalLM,
18
+ )
19
+
20
+ ###############################################################
21
+ # MODEL / LANGUAGE CONSTANTS
22
+ ###############################################################
23
+
24
+ QWEN_MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
25
+ WHISPER_MODEL_SIZE = "base" # you can change to "large-v3" if GPU budget allows
26
+
27
+ _QWEN_TOKENIZER = None
28
+ _QWEN_MODEL = None
29
+ _WHISPER = None
30
+
31
+ # Spoken language -> Whisper hint
32
+ WHISPER_LANG_MAP = {
33
+ "english": "en",
34
+ "german": "de",
35
+ "spanish": "es",
36
+ "russian": "ru",
37
+ "french": "fr",
38
+ "italian": "it",
39
+ "japanese": "ja",
40
+ "chinese": "zh",
41
+ "korean": "ko",
42
+ "arabic": "ar",
43
+ "hindi": "hi",
44
+ }
45
+
46
+ # Spoken language -> gTTS language code
47
+ GTTS_LANG = {
48
+ "english": "en",
49
+ "spanish": "es",
50
+ "german": "de",
51
+ "russian": "ru",
52
+ "japanese": "ja",
53
+ "chinese": "zh-cn",
54
+ "korean": "ko",
55
+ "french": "fr",
56
+ "italian": "it",
57
+ }
58
+
59
+ CONTROL_PROMPTS = {
60
+ "A1": "Use extremely short, simple sentences and very basic vocabulary.",
61
+ "A2": "Use simple sentences and common everyday vocabulary.",
62
+ "B1": "Use moderately complex sentences and conversational vocabulary.",
63
+ "B2": "Use natural, fluent sentences with richer vocabulary.",
64
+ "C1": "Use complex, advanced sentences with nuanced expressions.",
65
+ "C2": "Use highly sophisticated, near-native language and style.",
66
+ }
67
+
68
+
69
+ ###############################################################
70
+ # GLOBAL LOADERS
71
+ ###############################################################
72
+
73
+ def load_partner_lm() -> Tuple[AutoTokenizer, AutoModelForCausalLM]:
74
+ global _QWEN_TOKENIZER, _QWEN_MODEL
75
+ if _QWEN_TOKENIZER is not None and _QWEN_MODEL is not None:
76
+ return _QWEN_TOKENIZER, _QWEN_MODEL
77
+
78
+ print("[conversation_core] Loading partner LM:", QWEN_MODEL_NAME)
79
+ tok = AutoTokenizer.from_pretrained(QWEN_MODEL_NAME, trust_remote_code=True)
80
+ model = AutoModelForCausalLM.from_pretrained(
81
+ QWEN_MODEL_NAME,
82
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
83
+ device_map="auto",
84
+ trust_remote_code=True,
85
+ )
86
+ _QWEN_TOKENIZER = tok
87
+ _QWEN_MODEL = model
88
+ return tok, model
89
+
90
+
91
+ def load_whisper() -> WhisperModel:
92
+ global _WHISPER
93
+ if _WHISPER is not None:
94
+ return _WHISPER
95
+
96
+ device = "cuda" if torch.cuda.is_available() else "cpu"
97
+ compute_type = "float16" if device == "cuda" else "int8"
98
+ print(f"[conversation_core] Loading Whisper {WHISPER_MODEL_SIZE} on {device} ({compute_type})")
99
+ _WHISPER = WhisperModel(WHISPER_MODEL_SIZE, device=device, compute_type=compute_type)
100
+ return _WHISPER
101
+
102
+
103
+ ###############################################################
104
+ # DATA STRUCTURE
105
+ ###############################################################
106
+
107
+ @dataclass
108
+ class ConversationTurn:
109
+ role: str
110
+ text: str
111
+
112
+
113
+ ###############################################################
114
+ # CLEANING
115
+ ###############################################################
116
+
117
+ def clean_assistant_reply(text: str) -> str:
118
+ """Strip meta, identity, and obvious junk from LM output."""
119
+ if not text:
120
+ return ""
121
+
122
+ # Remove labels
123
+ text = re.sub(r"(?i)\b(user|assistant|system)\s*:\s*", "", text)
124
+
125
+ # Remove numbered / bullet lists (not wanted in casual chat)
126
+ text = re.sub(r"(?m)^\s*[-•*]\s+.*$", "", text)
127
+ text = re.sub(r"(?m)^\s*\d+\.\s+.*$", "", text)
128
+
129
+ # Remove obvious identity / HR / meta nonsense
130
+ identity_patterns = [
131
+ r"(?i)i am (an?|the)? ?(ai|assistant|speaker|model|natural person).*",
132
+ r"(?i)my name is [A-Za-zäöüÄÖÜß]+.*",
133
+ r"(?i)i was created.*",
134
+ r"(?i)human resources manager.*",
135
+ r"(?i)job description.*",
136
+ r"(?i)i am a large language model.*",
137
+ ]
138
+ for pat in identity_patterns:
139
+ text = re.sub(pat, "", text)
140
+
141
+ # Trim hanging word fragments at the end
142
+ text = re.sub(r"[A-Za-zÄÖÜäöüß]+$", "", text)
143
+
144
+ # Collapse whitespace
145
+ text = re.sub(r"\s{2,}", " ", text)
146
+ return text.strip()
147
+
148
+
149
+ ###############################################################
150
+ # CONVERSATION MANAGER
151
+ ###############################################################
152
+
153
+ class ConversationManager:
154
+ def __init__(
155
+ self,
156
+ target_language: str = "german",
157
+ native_language: str = "english",
158
+ cefr_level: str = "B1",
159
+ topic: str = "general conversation",
160
+ ):
161
+ self.target_language = (target_language or "english").strip().lower()
162
+ self.native_language = (native_language or "english").strip().lower()
163
+ self.cefr_level = cefr_level or "B1"
164
+ self.topic = topic or "general conversation"
165
+ self.history: List[ConversationTurn] = []
166
+
167
+ # Warm-load models once per session
168
+ load_partner_lm()
169
+ load_whisper()
170
+
171
+ ###########################################################
172
+ # PROMPT + GENERATION
173
+ ###########################################################
174
+
175
+ def _build_system_prompt(self) -> str:
176
+ base = (
177
+ f"You are a friendly conversation partner speaking {self.target_language}. "
178
+ f"Reply ONLY in {self.target_language}. "
179
+ f"Do NOT explain grammar, vocabulary, or translations unless the user explicitly asks. "
180
+ f"Do NOT describe what the sentence means, do NOT say 'the sentence translates to...', "
181
+ f"and do NOT mention that you are explaining anything. "
182
+ f"Adapt your language to CEFR level {self.cefr_level}. "
183
+ f"{CONTROL_PROMPTS.get(self.cefr_level, '')} "
184
+ "Keep your replies natural and conversational, usually 1–3 short sentences. "
185
+ "Ask exactly ONE natural follow-up question related to what the user said. "
186
+ "Never end the conversation unless the user explicitly ends it. "
187
+ "Do NOT say goodbye or conclude unless the user does. "
188
+ "Never talk about being an AI, model, or assistant. "
189
+ "Do not mention job descriptions, resumes, or HR responsibilities unless the user clearly asks. "
190
+ )
191
+ if self.topic.strip():
192
+ base += f"The main topic of conversation is: {self.topic.strip()}. "
193
+ return base
194
+
195
+ def _generate_lm(self, user_text: str) -> str:
196
+ tok, model = load_partner_lm()
197
+
198
+ system_prompt = self._build_system_prompt()
199
+ messages = [
200
+ {"role": "system", "content": system_prompt},
201
+ {
202
+ "role": "user",
203
+ "content": f"The user (who speaks {self.native_language}) said: {user_text}",
204
+ },
205
+ ]
206
+
207
+ prompt = tok.apply_chat_template(
208
+ messages,
209
+ tokenize=False,
210
+ add_generation_prompt=True,
211
+ )
212
+
213
+ enc = tok(prompt, return_tensors="pt").to(model.device)
214
+
215
+ with torch.no_grad():
216
+ out = model.generate(
217
+ **enc,
218
+ max_new_tokens=160, # enough space for natural replies
219
+ temperature=0.8,
220
+ top_p=0.95,
221
+ top_k=50,
222
+ repetition_penalty=1.15,
223
+ pad_token_id=tok.eos_token_id,
224
+ do_sample=True,
225
+ )
226
+
227
+ raw = tok.decode(out[0], skip_special_tokens=True).strip()
228
+
229
+ # If the user text is echoed, strip it
230
+ if user_text in raw:
231
+ raw = raw.split(user_text)[-1].strip()
232
+
233
+ # Remove "assistant" label echoes
234
+ lines = [
235
+ ln for ln in raw.splitlines()
236
+ if ln.strip().lower() not in ("assistant", "assistant:")
237
+ ]
238
+ raw = "\n".join(lines).strip()
239
+
240
+ return clean_assistant_reply(raw)
241
+
242
+ ###########################################################
243
+ # PUBLIC REPLY API
244
+ ###########################################################
245
+
246
+ def reply(self, user_text: str, input_lang: str = "german"):
247
+ """Generate a reply + explanation + TTS audio."""
248
+ self.history.append(ConversationTurn("user", user_text))
249
+
250
+ assistant_text = self._generate_lm(user_text)
251
+ self.history.append(ConversationTurn("assistant", assistant_text))
252
+
253
+ explanation = self._generate_explanation(assistant_text)
254
+ audio = self.text_to_speech(assistant_text)
255
+
256
+ return {
257
+ "reply_text": assistant_text,
258
+ "explanation": explanation,
259
+ "audio": audio,
260
+ }
261
+
262
+ ###########################################################
263
+ # SHORT EXPLANATION (EN / native language)
264
+ ###########################################################
265
+
266
+ def _generate_explanation(self, assistant_text: str) -> str:
267
+ """Return exactly ONE simple native-language sentence, no meta, no logic."""
268
+ if not assistant_text:
269
+ return ""
270
+
271
+ tok, model = load_partner_lm()
272
+ prompt = (
273
+ f"Rewrite the meaning of this {self.target_language} sentence "
274
+ f"in ONE very short {self.native_language} sentence. "
275
+ f"Do NOT explain what you are doing, do NOT say 'the sentence means', "
276
+ f"do NOT describe tone, and do NOT provide multiple versions.\n"
277
+ f"Sentence: \"{assistant_text}\""
278
+ )
279
+
280
+ enc = tok(prompt, return_tensors="pt").to(model.device)
281
+ with torch.no_grad():
282
+ out = model.generate(
283
+ **enc,
284
+ max_new_tokens=40,
285
+ temperature=0.6,
286
+ top_p=0.9,
287
+ pad_token_id=tok.eos_token_id,
288
+ )
289
+
290
+ raw = tok.decode(out[0], skip_special_tokens=True)
291
+ raw = raw.replace(prompt, "").strip()
292
+
293
+ # keep first sentence only
294
+ parts = re.split(r"(?<=[.!?])\s+", raw)
295
+ if parts:
296
+ raw = parts[0].strip()
297
+
298
+ # remove meta leftovers
299
+ raw = re.sub(r"(?i)the sentence.*$", "", raw)
300
+ raw = re.sub(r"(?i)this means.*$", "", raw)
301
+
302
+ return raw.strip()
303
+
304
+
305
+
306
+ ###########################################################
307
+ # AUDIO TRANSCRIPTION
308
+ ###########################################################
309
+
310
+ def transcribe(self, audio_segment, spoken_lang: str = "english"):
311
+ """
312
+ Faster-Whisper transcription with optional language hint.
313
+ Returns (text, detected_lang_or_hint, dummy_confidence).
314
+ """
315
+ whisper = load_whisper()
316
+
317
+ lang_key = (spoken_lang or "english").strip().lower()
318
+ lang_hint = WHISPER_LANG_MAP.get(lang_key)
319
+
320
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
321
+ # Normalize and export pydub AudioSegment
322
+ audio_segment.set_channels(1).set_frame_rate(16000).export(
323
+ tmp.name, format="wav"
324
+ )
325
+
326
+ decode_opts = {"beam_size": 5}
327
+ if lang_hint:
328
+ decode_opts["language"] = lang_hint
329
+
330
+ segments, info = whisper.transcribe(tmp.name, **decode_opts)
331
+
332
+ full_text = " ".join(getattr(seg, "text", "") for seg in segments)
333
+ return full_text.strip(), (lang_hint or "auto"), 1.0
334
+
335
+ ###########################################################
336
+ # TEXT → SPEECH
337
+ ###########################################################
338
+
339
+ def text_to_speech(self, text: str) -> Optional[bytes]:
340
+ """Return MP3 bytes for the assistant text, or None on failure."""
341
+ if not text:
342
+ return None
343
+ try:
344
+ lang_code = GTTS_LANG.get(self.target_language, "en")
345
+ tts = gTTS(text=text, lang=lang_code)
346
+ buf = io.BytesIO()
347
+ tts.write_to_fp(buf)
348
+ return buf.getvalue()
349
+ except Exception:
350
+ return None
351
+
352
+
353
+ ###############################################################
354
+ # END OF FILE
355
+ ###############################################################
356
+
357
+
src/app/flashcards_tools.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/app/flashcards_tools.py
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Dict, List, Tuple, Optional
8
+
9
+ from deep_translator import GoogleTranslator
10
+
11
+ from app.config import get_user_dir
12
+
13
+
14
+ def _get_decks_dir(username: str) -> Path:
15
+ """
16
+ Returns the directory where all of a user's decks are stored.
17
+ """
18
+ user_dir = get_user_dir(username)
19
+ decks_dir = user_dir / "decks"
20
+ decks_dir.mkdir(parents=True, exist_ok=True)
21
+ return decks_dir
22
+
23
+
24
+ def list_user_decks(username: str) -> Dict[str, Path]:
25
+ """
26
+ Returns a mapping of deck name -> deck json path.
27
+ Deck name is taken from the deck's "name" field if present,
28
+ otherwise the filename stem.
29
+ """
30
+ decks_dir = _get_decks_dir(username)
31
+ deck_files = sorted(decks_dir.glob("*.json"))
32
+ decks: Dict[str, Path] = {}
33
+
34
+ for path in deck_files:
35
+ try:
36
+ data = json.loads(path.read_text(encoding="utf-8"))
37
+ name = data.get("name") or path.stem
38
+ except Exception:
39
+ name = path.stem
40
+
41
+ # ensure uniqueness by appending stem if needed
42
+ if name in decks and decks[name] != path:
43
+ name = f"{name} ({path.stem})"
44
+ decks[name] = path
45
+
46
+ return decks
47
+
48
+
49
+ def _ensure_card_stats(card: Dict) -> None:
50
+ """
51
+ Ensure that a card has simple spaced-repetition stats.
52
+ """
53
+ if "score" not in card: # learning strength
54
+ card["score"] = 0
55
+ if "reviews" not in card:
56
+ card["reviews"] = 0
57
+
58
+
59
+ def load_deck(path: Path) -> Dict:
60
+ """
61
+ Loads a deck from JSON, ensuring 'cards' exists and that
62
+ each card has basic stats for spaced repetition.
63
+ """
64
+ try:
65
+ data = json.loads(path.read_text(encoding="utf-8"))
66
+ except Exception:
67
+ data = {}
68
+ if "cards" not in data or not isinstance(data["cards"], list):
69
+ data["cards"] = []
70
+ if "name" not in data:
71
+ data["name"] = path.stem
72
+ if "tags" not in data or not isinstance(data["tags"], list):
73
+ data["tags"] = []
74
+
75
+ for card in data["cards"]:
76
+ _ensure_card_stats(card)
77
+
78
+ return data
79
+
80
+
81
+ def save_deck(path: Path, deck: Dict) -> None:
82
+ """
83
+ Saves deck to JSON.
84
+ """
85
+ if "cards" not in deck:
86
+ deck["cards"] = []
87
+ if "name" not in deck:
88
+ deck["name"] = path.stem
89
+ if "tags" not in deck or not isinstance(deck["tags"], list):
90
+ deck["tags"] = []
91
+
92
+ # make sure stats are present
93
+ for card in deck["cards"]:
94
+ _ensure_card_stats(card)
95
+
96
+ path.write_text(json.dumps(deck, indent=2, ensure_ascii=False), encoding="utf-8")
97
+
98
+
99
+ # ------------------------------------------------------------
100
+ # Shared tokenization
101
+ # ------------------------------------------------------------
102
+
103
+ def _extract_candidate_words(text: str) -> List[str]:
104
+ """
105
+ Simple tokenizer & filter for candidate vocab words.
106
+ """
107
+ tokens = re.findall(r"\b\w+\b", text, flags=re.UNICODE)
108
+ out = []
109
+ seen = set()
110
+ for t in tokens:
111
+ t_norm = t.strip()
112
+ if len(t_norm) < 2:
113
+ continue
114
+ if any(ch.isdigit() for ch in t_norm):
115
+ continue
116
+ lower = t_norm.lower()
117
+ if lower in seen:
118
+ continue
119
+ seen.add(lower)
120
+ out.append(t_norm)
121
+ return out
122
+
123
+
124
+ # ------------------------------------------------------------
125
+ # OCR → Flashcards
126
+ # ------------------------------------------------------------
127
+
128
+ def generate_flashcards_from_ocr_results(
129
+ username: str,
130
+ ocr_results: List[Dict],
131
+ deck_name: str = "ocr",
132
+ target_lang: str = "en",
133
+ tags: Optional[List[str]] = None,
134
+ ) -> Path:
135
+ """
136
+ Takes OCR results (as produced by ocr_tools.ocr_and_translate_batch)
137
+ and constructs a simple vocab deck.
138
+
139
+ ocr_results: list of dict with keys:
140
+ - "text": original text
141
+ - optionally other fields (ignored)
142
+ """
143
+ all_text = []
144
+ for res in ocr_results:
145
+ t = res.get("text") or res.get("raw_text") or ""
146
+ if t:
147
+ all_text.append(t)
148
+ joined = "\n".join(all_text)
149
+
150
+ words = _extract_candidate_words(joined)
151
+ if not words:
152
+ raise ValueError("No candidate words found in OCR results.")
153
+
154
+ translator = GoogleTranslator(source="auto", target=target_lang)
155
+ cards = []
156
+ for w in words:
157
+ try:
158
+ trans = translator.translate(w)
159
+ except Exception:
160
+ continue
161
+ if not trans:
162
+ continue
163
+ if trans.strip().lower() == w.strip().lower():
164
+ continue
165
+ card = {
166
+ "front": w,
167
+ "back": trans,
168
+ "content_type": "ocr_vocab",
169
+ "language": target_lang,
170
+ }
171
+ _ensure_card_stats(card)
172
+ cards.append(card)
173
+
174
+ if not cards:
175
+ raise ValueError("No translatable words found to build cards.")
176
+
177
+ decks_dir = _get_decks_dir(username)
178
+ deck_path = decks_dir / f"{deck_name}.json"
179
+
180
+ deck = {
181
+ "name": deck_name,
182
+ "cards": cards,
183
+ "tags": tags or [],
184
+ }
185
+ save_deck(deck_path, deck)
186
+ return deck_path
187
+
188
+
189
+ # ------------------------------------------------------------
190
+ # Conversation/Text → Flashcards
191
+ # ------------------------------------------------------------
192
+
193
+ def generate_flashcards_from_text(
194
+ username: str,
195
+ text: str,
196
+ deck_name: str = "conversation",
197
+ target_lang: str = "en",
198
+ tags: Optional[List[str]] = None,
199
+ ) -> Path:
200
+ """
201
+ Build a vocab deck from raw conversation text.
202
+ """
203
+ words = _extract_candidate_words(text)
204
+ if not words:
205
+ raise ValueError("No candidate words found in text.")
206
+
207
+ translator = GoogleTranslator(source="auto", target=target_lang)
208
+ cards = []
209
+ for w in words:
210
+ try:
211
+ trans = translator.translate(w)
212
+ except Exception:
213
+ continue
214
+ if not trans:
215
+ continue
216
+ if trans.strip().lower() == w.strip().lower():
217
+ continue
218
+ card = {
219
+ "front": w,
220
+ "back": trans,
221
+ "content_type": "conversation_vocab",
222
+ "language": target_lang,
223
+ }
224
+ _ensure_card_stats(card)
225
+ cards.append(card)
226
+
227
+ if not cards:
228
+ raise ValueError("No translatable words found to build cards.")
229
+
230
+ decks_dir = _get_decks_dir(username)
231
+ deck_path = decks_dir / f"{deck_name}.json"
232
+
233
+ deck = {
234
+ "name": deck_name,
235
+ "cards": cards,
236
+ "tags": tags or ["conversation"],
237
+ }
238
+ save_deck(deck_path, deck)
239
+ return deck_path
240
+
241
+
src/app/main_app.py ADDED
@@ -0,0 +1,1235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ###############################################################
3
+ # main_app.py — Agentic Language Partner UI (Streamlit)
4
+ ###############################################################
5
+ import pandas as pd
6
+ import json
7
+ import random
8
+ import re
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Dict, List, Any
12
+
13
+ import streamlit as st
14
+ import streamlit.components.v1 as components
15
+ from audiorecorder import audiorecorder
16
+ from deep_translator import GoogleTranslator
17
+
18
+ from app.auth import (
19
+ authenticate_user,
20
+ register_user,
21
+ get_user_prefs,
22
+ update_user_prefs,
23
+ )
24
+ from app.config import get_user_dir
25
+ from app.conversation_core import ConversationManager
26
+ from app.flashcards_tools import (
27
+ list_user_decks,
28
+ load_deck,
29
+ _get_decks_dir,
30
+ save_deck,
31
+ generate_flashcards_from_text,
32
+ generate_flashcards_from_ocr_results,
33
+ )
34
+ from app.ocr_tools import ocr_and_translate_batch
35
+ from app.viewers import generate_flashcard_viewer_for_user
36
+
37
+
38
+ ###############################################################
39
+ # PAGE + GLOBAL STYLE
40
+ ###############################################################
41
+
42
+ st.set_page_config(
43
+ page_title="Agentic Language Partner",
44
+ layout="wide",
45
+ page_icon="🌐",
46
+ )
47
+
48
+ st.markdown(
49
+ """
50
+ <style>
51
+
52
+ .chat-column {
53
+ display: flex;
54
+ flex-direction: column;
55
+ }
56
+
57
+
58
+
59
+ /* Input bar at the top */
60
+ .chat-input-bar {
61
+ margin-bottom: 0.5rem;
62
+ background-color: #111;
63
+ padding: 0.75rem 0.5rem 0.5rem;
64
+ border: 1px solid #333;
65
+ border-radius: 0.5rem;
66
+ }
67
+
68
+ /* Scrollable chat messages below input */
69
+ .chat-window {
70
+ max-height: 65vh;
71
+ overflow-y: auto;
72
+ padding-right: .75rem;
73
+ padding-bottom: 0.5rem;
74
+ }
75
+
76
+ /* Chat bubbles */
77
+ .chat-row-user { justify-content:flex-end; display:flex; margin-bottom:.4rem; }
78
+ .chat-row-assistant { justify-content:flex-start; display:flex; margin-bottom:.4rem; }
79
+
80
+ .chat-bubble {
81
+ border-radius:14px;
82
+ padding:.55rem .95rem;
83
+ max-width:80%;
84
+ line-height:1.4;
85
+ box-shadow:0 2px 5px rgba(0,0,0,0.4);
86
+ font-size:1.05rem; /* larger for readability */
87
+ }
88
+ .chat-bubble-user { background:#3a3b3c; color:white; }
89
+ .chat-bubble-assistant { background:#1a73e8; color:white; }
90
+
91
+ .chat-aux {
92
+ font-size:1.0rem; /* larger translation/explanation */
93
+ color:#ccc;
94
+ margin:0.1rem 0.25rem 0.5rem 0.25rem;
95
+ }
96
+
97
+ /* Lock viewport height and avoid infinite page scrolling */
98
+ html, body {
99
+ height: 100%;
100
+ overflow: hidden !important;
101
+ }
102
+
103
+ .block-container {
104
+ height: 100vh !important;
105
+ overflow-y: auto !important;
106
+ }
107
+
108
+ .saved-conv-panel { max-width: 360px; }
109
+
110
+ </style>
111
+ """,
112
+ unsafe_allow_html=True,
113
+ )
114
+
115
+
116
+ ###############################################################
117
+ # HELPERS / GLOBALS
118
+ ###############################################################
119
+ # ------------------------------------------------------------
120
+ # Model preload / Conversation manager
121
+ # ------------------------------------------------------------
122
+
123
+ def preload_models():
124
+ """
125
+ Preloads Whisper, Qwen, and NLLB models only one time.
126
+ This prevents UI freeze or repeated loading during conversation.
127
+ """
128
+ if st.session_state.get("models_loaded"):
129
+ return
130
+
131
+ with st.spinner("Loading language & speech models (one-time)…"):
132
+ try:
133
+ load_whisper()
134
+ except Exception:
135
+ pass
136
+ try:
137
+ load_partner_lm()
138
+ except Exception:
139
+ pass
140
+ try:
141
+ load_nllb()
142
+ except Exception:
143
+ pass
144
+
145
+ st.session_state["models_loaded"] = True
146
+
147
+
148
+
149
+ def get_conv_manager() -> ConversationManager:
150
+ if "conv_manager" not in st.session_state:
151
+ prefs = st.session_state["prefs"]
152
+ st.session_state["conv_manager"] = ConversationManager(
153
+ target_language=prefs.get("target_language", "english"),
154
+ native_language=prefs.get("native_language", "english"),
155
+ cefr_level=prefs.get("cefr_level", "B1"),
156
+ topic=prefs.get("topic", "general conversation"),
157
+ )
158
+ return st.session_state["conv_manager"]
159
+
160
+
161
+ def ensure_default_decks(username: str):
162
+ decks_dir = _get_decks_dir(username)
163
+
164
+ alpha = decks_dir / "alphabet.json"
165
+ if not alpha.exists():
166
+ save_deck(alpha, {
167
+ "name": "Alphabet (A–Z)",
168
+ "cards": [{"front": chr(65+i), "back": f"Letter {chr(65+i)}"} for i in range(26)],
169
+ "tags": ["starter"],
170
+ })
171
+
172
+ nums = decks_dir / "numbers_1_10.json"
173
+ if not nums.exists():
174
+ save_deck(nums, {
175
+ "name": "Numbers 1–10",
176
+ "cards": [{"front": str(i), "back": f"Number {i}"} for i in range(1, 11)],
177
+ "tags": ["starter"],
178
+ })
179
+
180
+ greetings = decks_dir / "greetings_intros.json"
181
+ if not greetings.exists():
182
+ save_deck(greetings, {
183
+ "name": "Greetings & Introductions",
184
+ "cards": [
185
+ {"front": "Hallo!", "back": "Hello!"},
186
+ {"front": "Wie geht's?", "back": "How are you?"},
187
+ {"front": "Ich heiße …", "back": "My name is …"},
188
+ {"front": "Freut mich!", "back": "Nice to meet you!"},
189
+ ],
190
+ "tags": ["starter"],
191
+ })
192
+
193
+
194
+ def ui_clean_assistant_text(text: str) -> str:
195
+ if not text:
196
+ return ""
197
+ text = re.sub(r"(?i)\b(user|assistant|system):\s*", "", text)
198
+ text = re.sub(r"\s{2,}", " ", text)
199
+ return text.strip()
200
+
201
+
202
+ def save_current_conversation(username: str, name: str) -> Path:
203
+ """Save chat_history as JSON, stripping non-serializable fields (audio bytes)."""
204
+ user_dir = get_user_dir(username)
205
+ save_dir = user_dir / "chats" / "saved"
206
+ save_dir.mkdir(parents=True, exist_ok=True)
207
+
208
+ cleaned_messages = []
209
+ for m in st.session_state.get("chat_history", []):
210
+ cleaned_messages.append(
211
+ {
212
+ "role": m.get("role"),
213
+ "text": m.get("text"),
214
+ "explanation": m.get("explanation"),
215
+ # store only a flag for audio, not raw bytes
216
+ "audio_present": bool(m.get("audio")),
217
+ }
218
+ )
219
+
220
+ payload = {
221
+ "name": name,
222
+ "timestamp": datetime.utcnow().isoformat(),
223
+ "messages": cleaned_messages,
224
+ }
225
+ fname = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + ".json"
226
+ path = save_dir / fname
227
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
228
+ return path
229
+
230
+
231
+
232
+ ###############################################################
233
+ # CHAT HANDLING
234
+ ###############################################################
235
+
236
+ def handle_user_message(username: str, text: str):
237
+ text = text.strip()
238
+ if not text:
239
+ return
240
+
241
+ conv = get_conv_manager()
242
+
243
+ st.session_state["chat_history"].append(
244
+ {"role": "user", "text": text, "audio": None, "explanation": None}
245
+ )
246
+
247
+ with st.spinner("Thinking…"):
248
+ result = conv.reply(text)
249
+
250
+ reply_text = ui_clean_assistant_text(result.get("reply_text", ""))
251
+ reply_audio = result.get("audio", None)
252
+ explanation = ui_clean_assistant_text(result.get("explanation", ""))
253
+
254
+ st.session_state["chat_history"].append(
255
+ {
256
+ "role": "assistant",
257
+ "text": reply_text,
258
+ "audio": reply_audio,
259
+ "explanation": explanation,
260
+ }
261
+ )
262
+
263
+
264
+ ###############################################################
265
+ # AUTH
266
+ ###############################################################
267
+
268
+ def login_view():
269
+ st.title("🌐 Agentic Language Partner")
270
+ tab1, tab2 = st.tabs(["Login", "Register"])
271
+
272
+ with tab1:
273
+ u = st.text_input("Username")
274
+ p = st.text_input("Password", type="password")
275
+ if st.button("Login"):
276
+ if authenticate_user(u, p):
277
+ st.session_state["user"] = u
278
+ st.session_state["prefs"] = get_user_prefs(u)
279
+ st.experimental_rerun()
280
+ else:
281
+ st.error("Invalid login.")
282
+
283
+ with tab2:
284
+ u = st.text_input("New username")
285
+ p = st.text_input("New password", type="password")
286
+ if st.button("Register"):
287
+ if register_user(u, p):
288
+ st.success("Registered! Please log in.")
289
+ else:
290
+ st.error("Username already exists.")
291
+
292
+
293
+ ###############################################################
294
+ # SIDEBAR SETTINGS
295
+ ###############################################################
296
+
297
+ def sidebar_settings(username: str):
298
+ st.sidebar.header("⚙ Settings")
299
+
300
+ prefs = st.session_state["prefs"]
301
+ langs = ["english", "spanish", "german", "russian", "japanese", "chinese", "korean"]
302
+
303
+ tgt = st.sidebar.selectbox(
304
+ "Target language",
305
+ langs,
306
+ index=langs.index(prefs.get("target_language", "english")),
307
+ key="sidebar_target",
308
+ )
309
+ nat = st.sidebar.selectbox(
310
+ "Native language",
311
+ langs,
312
+ index=langs.index(prefs.get("native_language", "english")),
313
+ key="sidebar_native",
314
+ )
315
+
316
+ cefr_levels = ["A1", "A2", "B1", "B2", "C1", "C2"]
317
+ level = st.sidebar.selectbox(
318
+ "CEFR Level",
319
+ cefr_levels,
320
+ index=cefr_levels.index(prefs.get("cefr_level", "B1")),
321
+ key="sidebar_cefr",
322
+ )
323
+
324
+ topic = st.sidebar.text_input(
325
+ "Conversation Topic",
326
+ prefs.get("topic", "general conversation"),
327
+ key="sidebar_topic",
328
+ )
329
+
330
+ show_exp = st.sidebar.checkbox(
331
+ "Show Explanations",
332
+ value=prefs.get("show_explanations", True),
333
+ key="sidebar_show_exp",
334
+ )
335
+
336
+ if st.sidebar.button("Save Settings"):
337
+ new = {
338
+ "target_language": tgt,
339
+ "native_language": nat,
340
+ "cefr_level": level,
341
+ "topic": topic,
342
+ "show_explanations": show_exp,
343
+ }
344
+ st.session_state["prefs"] = new
345
+ update_user_prefs(username, new)
346
+ if "conv_manager" in st.session_state:
347
+ del st.session_state["conv_manager"]
348
+ st.sidebar.success("Settings saved!")
349
+
350
+
351
+ ###############################################################
352
+ # DASHBOARD TAB
353
+ ###############################################################
354
+
355
+ def dashboard_tab(username: str):
356
+ st.title("Agentic Language Partner — Dashboard")
357
+
358
+ prefs = st.session_state["prefs"]
359
+ langs = ["english", "spanish", "german", "russian", "japanese", "chinese", "korean"]
360
+
361
+ st.subheader("Language Settings")
362
+
363
+ col1, col2, col3 = st.columns(3)
364
+
365
+ with col1:
366
+ native = st.selectbox(
367
+ "Native language",
368
+ langs,
369
+ index=langs.index(prefs.get("native_language", "english")),
370
+ key="dash_native_language",
371
+ )
372
+ with col2:
373
+ target = st.selectbox(
374
+ "Target language",
375
+ langs,
376
+ index=langs.index(prefs.get("target_language", "english")),
377
+ key="dash_target_language",
378
+ )
379
+ with col3:
380
+ cefr_levels = ["A1", "A2", "B1", "B2", "C1", "C2"]
381
+ level = st.selectbox(
382
+ "CEFR Level",
383
+ cefr_levels,
384
+ index=cefr_levels.index(prefs.get("cefr_level", "B1")),
385
+ key="dash_cefr_level",
386
+ )
387
+
388
+ topic = st.text_input(
389
+ "Conversation Topic",
390
+ prefs.get("topic", "general conversation"),
391
+ key="dash_topic",
392
+ )
393
+
394
+ if st.button("Save Language Settings", key="dash_save_lang"):
395
+ new = {
396
+ "native_language": native,
397
+ "target_language": target,
398
+ "cefr_level": level,
399
+ "topic": topic,
400
+ "show_explanations": prefs.get("show_explanations", True),
401
+ }
402
+ st.session_state["prefs"] = new
403
+ update_user_prefs(username, new)
404
+ if "conv_manager" in st.session_state:
405
+ del st.session_state["conv_manager"]
406
+ st.success("Language settings saved!")
407
+
408
+ st.markdown("---")
409
+
410
+ ###########################################################
411
+ # MICROPHONE & TRANSCRIPTION CALIBRATION (native phrase)
412
+ ###########################################################
413
+ # ---- Microphone & transcription calibration (restored original version) ----
414
+ st.subheader("Microphone & Transcription Calibration")
415
+
416
+ st.write(
417
+ "To verify that audio recording and transcription are working, "
418
+ "please repeat this phrase in your native language:\n\n"
419
+ "> \"Hello, my name is [Your Name], and I am here to practice languages.\"\n\n"
420
+ "Record a short clip and then run transcription to check accuracy."
421
+ )
422
+
423
+ calib_col1, calib_col2 = st.columns([2, 1])
424
+
425
+ with calib_col1:
426
+ calib_audio = audiorecorder(
427
+ "🎤 Start calibration",
428
+ "⏹ Stop",
429
+ key="calibration_audio",
430
+ )
431
+
432
+ if len(calib_audio) > 0:
433
+ st.caption("Calibration audio recorded. Click 'Transcribe sample' to test.")
434
+
435
+ if st.button("Transcribe sample", key="calibration_transcribe"):
436
+ if len(calib_audio) == 0:
437
+ st.warning("Please record a short calibration clip first.")
438
+ else:
439
+ conv = get_conv_manager()
440
+ try:
441
+ seg = calib_audio.set_frame_rate(16000).set_channels(1)
442
+ with st.spinner("Transcribing calibration audio…"):
443
+ text_out, det_lang, det_prob = conv.transcribe(
444
+ seg,
445
+ spoken_lang=st.session_state["prefs"]["native_language"]
446
+ )
447
+ st.session_state["calibration_result"] = {
448
+ "text": text_out,
449
+ "det_lang": det_lang,
450
+ "det_prob": det_prob,
451
+ }
452
+ st.success("Calibration transcript updated.")
453
+ except Exception as e:
454
+ st.error(f"Calibration error: {e}")
455
+
456
+ with calib_col2:
457
+ if st.session_state.get("calibration_result"):
458
+ res = st.session_state["calibration_result"]
459
+ st.markdown("**Calibration transcript:**")
460
+ st.info(res.get("text", ""))
461
+ st.caption(
462
+ f"Detected lang: {res.get('det_lang','?')} · Confidence ~ {res.get('det_prob', 0):.2f}"
463
+ )
464
+ else:
465
+ st.caption("No calibration transcript yet.")
466
+
467
+ st.markdown("---")
468
+
469
+
470
+
471
+
472
+ ###########################################################
473
+ # TOOL OVERVIEW
474
+ ###########################################################
475
+ st.subheader("Tools Overview")
476
+
477
+ c1, c2, c3 = st.columns(3)
478
+ with c1:
479
+ st.markdown("### 🎙️ Conversation Partner")
480
+ st.write("Real-time language practice with microphone support.")
481
+
482
+ with c2:
483
+ st.markdown("### 🃏 Flashcards & Quizzes")
484
+ st.write("Starter decks: Alphabet, Numbers, Greetings.")
485
+
486
+ with c3:
487
+ st.markdown("### 📷 OCR Helper")
488
+ st.write("Upload images to extract and translate text.")
489
+
490
+ # ------------------------------------------------------------
491
+ # Settings tab (restore missing function)
492
+ # ------------------------------------------------------------
493
+ def settings_tab(username: str):
494
+ """Minimal settings tab so main() can call it safely."""
495
+ st.header("Settings")
496
+
497
+ st.subheader("User Preferences")
498
+ prefs = st.session_state.get("prefs", {})
499
+ st.json(prefs)
500
+
501
+ st.markdown("---")
502
+
503
+ st.subheader("System Status")
504
+ st.write("Models preloaded:", st.session_state.get("models_loaded", False))
505
+
506
+ st.markdown(
507
+ "This is a placeholder settings panel. "
508
+ "You can customize this later with user-specific configuration."
509
+ )
510
+
511
+
512
+
513
+ ###############################################################
514
+ # CONVERSATION TAB
515
+ ###############################################################
516
+
517
+ def conversation_tab(username: str):
518
+ import re
519
+ from datetime import datetime
520
+ from deep_translator import GoogleTranslator
521
+
522
+ st.header("Conversation")
523
+
524
+ # ------------------------------------------
525
+ # INITIAL STATE
526
+ # ------------------------------------------
527
+ if "chat_history" not in st.session_state:
528
+ st.session_state["chat_history"] = []
529
+
530
+ if "pending_transcript" not in st.session_state:
531
+ st.session_state["pending_transcript"] = ""
532
+
533
+ if "speech_state" not in st.session_state:
534
+ st.session_state["speech_state"] = "idle" # idle | pending_speech
535
+
536
+ if "recorder_key" not in st.session_state:
537
+ st.session_state["recorder_key"] = 0
538
+
539
+ conv = get_conv_manager()
540
+ prefs = st.session_state.get("prefs", {})
541
+ show_exp = prefs.get("show_explanations", True)
542
+
543
+ # ------------------------------------------
544
+ # RESET BUTTON (ONLY ONE)
545
+ # ------------------------------------------
546
+ if st.button("🔄 Reset Conversation"):
547
+ st.session_state["chat_history"] = []
548
+ st.session_state["pending_transcript"] = ""
549
+ st.session_state["speech_state"] = "idle"
550
+ st.session_state["recorder_key"] += 1
551
+ st.experimental_rerun()
552
+
553
+
554
+ # ------------------------------------------
555
+ # FIRST MESSAGE GREETING
556
+ # ------------------------------------------
557
+ if len(st.session_state["chat_history"]) == 0:
558
+ lang = conv.target_language.lower()
559
+ topic = prefs.get("topic", "").strip()
560
+
561
+ default_greetings = {
562
+ "english": "Hello! I heard you want to practice with me. How is your day going?",
563
+ "german": "Hallo! Ich habe gehört, dass du üben möchtest. Wie geht dein Tag bisher?",
564
+ "spanish": "¡Hola! Escuché que querías practicar conmigo. ¿Cómo va tu día?",
565
+ "japanese":"こんにちは!練習したいと聞きました。今日はどんな一日ですか?",
566
+ }
567
+
568
+ intro = default_greetings.get(lang, default_greetings["english"])
569
+
570
+ if topic and topic.lower() != "general conversation":
571
+ try:
572
+ intro = GoogleTranslator(source="en", target=lang).translate(
573
+ f"Hello! Let's talk about {topic}. What do you think about it?"
574
+ )
575
+ except Exception:
576
+ pass
577
+
578
+ st.session_state["chat_history"].append(
579
+ {"role":"assistant","text":intro,"audio":None,"explanation":None}
580
+ )
581
+
582
+ # ------------------------------------------
583
+ # LAYOUT
584
+ # ------------------------------------------
585
+ col_chat, col_saved = st.columns([3,1])
586
+
587
+ # ===========================
588
+ # LEFT: CHAT WINDOW
589
+ # ===========================
590
+ with col_chat:
591
+
592
+ st.markdown('<div class="chat-window">', unsafe_allow_html=True)
593
+
594
+ for msg in st.session_state["chat_history"]:
595
+ role = msg["role"]
596
+ bubble = "chat-bubble-user" if role == "user" else "chat-bubble-assistant"
597
+ row = "chat-row-user" if role == "user" else "chat-row-assistant"
598
+
599
+ st.markdown(
600
+ f'<div class="{row}"><div class="chat-bubble {bubble}">{msg["text"]}</div></div>',
601
+ unsafe_allow_html=True,
602
+ )
603
+
604
+ if role == "assistant" and msg.get("audio"):
605
+ st.audio(msg["audio"], format="audio/mp3")
606
+
607
+ if role == "assistant":
608
+ try:
609
+ tr = GoogleTranslator(source="auto", target=conv.native_language).translate(msg["text"])
610
+ st.markdown(f'<div class="chat-aux">{tr}</div>', unsafe_allow_html=True)
611
+ except: pass
612
+
613
+ if show_exp and msg.get("explanation"):
614
+ exp = msg["explanation"]
615
+
616
+ # Force EXACTLY ONE sentence
617
+ exp = re.split(r"(?<=[.!?])\s+", exp)[0].strip()
618
+
619
+ # Remove any meta nonsense ("version:", "meaning:", "this sentence", etc)
620
+ exp = re.sub(r"(?i)(english version|the meaning|this sentence|the german sentence).*", "", exp).strip()
621
+
622
+ if exp:
623
+ st.markdown(f'<div class="chat-aux">{exp}</div>', unsafe_allow_html=True)
624
+
625
+ # scroll
626
+ st.markdown("""
627
+ <script>
628
+ setTimeout(() => {
629
+ let w = window.parent.document.getElementsByClassName('chat-window')[0];
630
+ if (w) w.scrollTop = w.scrollHeight;
631
+ }, 200);
632
+ </script>
633
+ """, unsafe_allow_html=True)
634
+
635
+ # -------------------------------
636
+ # AUDIO RECORDER
637
+ # -------------------------------
638
+ st.markdown('<div class="sticky-input-bar">', unsafe_allow_html=True)
639
+
640
+ audio = audiorecorder("🎤 Speak", "⏹ Stop",
641
+ key=f"chat_audio_{st.session_state['recorder_key']}")
642
+
643
+ # ------------------------------------------
644
+ # STATE: idle → record → transcribe
645
+ # ------------------------------------------
646
+ if st.session_state["speech_state"] == "idle":
647
+ if audio and len(audio) > 0:
648
+ seg = audio.set_frame_rate(16000).set_channels(1)
649
+ with st.spinner("Transcribing…"):
650
+ txt, lang, conf = conv.transcribe(seg, spoken_lang=conv.target_language)
651
+
652
+ st.session_state["pending_transcript"] = txt.strip()
653
+ st.session_state["speech_state"] = "pending_speech"
654
+ st.session_state["recorder_key"] += 1
655
+ st.experimental_rerun()
656
+
657
+ # ------------------------------------------
658
+ # STATE: pending_speech → confirm
659
+ # ------------------------------------------
660
+ if st.session_state["speech_state"] == "pending_speech":
661
+
662
+ st.write("### Confirm your spoken message:")
663
+ st.info(st.session_state["pending_transcript"])
664
+
665
+ c1, c2 = st.columns([1,1])
666
+ with c1:
667
+ if st.button("Send message", key="send_pending"):
668
+ txt = st.session_state["pending_transcript"]
669
+
670
+ with st.spinner("Partner is responding…"):
671
+ handle_user_message(username, txt)
672
+
673
+ # cleanup
674
+ st.session_state["speech_state"] = "idle"
675
+ st.session_state["pending_transcript"] = ""
676
+ st.session_state["recorder_key"] += 1
677
+ st.experimental_rerun()
678
+
679
+ with c2:
680
+ if st.button("Discard", key="discard_pending"):
681
+ st.session_state["speech_state"] = "idle"
682
+ st.session_state["pending_transcript"] = ""
683
+ st.session_state["recorder_key"] += 1
684
+ st.experimental_rerun()
685
+
686
+ # -------------------------------
687
+ # TYPED TEXT INPUT
688
+ # -------------------------------
689
+ typed = st.text_input("Type your message:", key="typed_input")
690
+ if typed.strip() and st.button("Send typed message"):
691
+ handle_user_message(username, typed.strip())
692
+ st.session_state["typed_input"] = ""
693
+ st.experimental_rerun()
694
+
695
+ st.markdown("</div>", unsafe_allow_html=True)
696
+
697
+ # ======================================================
698
+ # RIGHT: SAVED CONVERSATIONS (RESTORED)
699
+ # ======================================================
700
+ with col_saved:
701
+ from pathlib import Path
702
+ import json
703
+
704
+ st.markdown("### Saved Conversations")
705
+
706
+ default_name = datetime.utcnow().strftime("Session %Y-%m-%d %H:%M")
707
+ name_box = st.text_input("Name conversation", value=default_name)
708
+
709
+ if st.button("Save conversation"):
710
+ if not st.session_state["chat_history"]:
711
+ st.warning("Nothing to save.")
712
+ else:
713
+ safe = re.sub(r"[^0-9A-Za-z_-]", "_", name_box)
714
+
715
+ path = save_current_conversation(username, safe)
716
+ st.success(f"Saved as {path.name}")
717
+
718
+ saved_dir = get_user_dir(username) / "chats" / "saved"
719
+ saved_dir.mkdir(parents=True, exist_ok=True)
720
+
721
+ files = sorted(saved_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
722
+
723
+ for f in files:
724
+ data = json.loads(f.read_text())
725
+ sess_name = data.get("name", f.stem)
726
+ msgs = data.get("messages", [])
727
+
728
+ with st.expander(f"{sess_name} ({len(msgs)} msgs)"):
729
+ deck_name = st.text_input(f"Deck name for {sess_name}", value=f"deck_{f.stem}")
730
+
731
+ if st.button(f"Export {f.stem}", key=f"export_{f.stem}"):
732
+ body = "\n".join(m["text"] for m in msgs if m["role"] == "assistant")
733
+ deck_path = generate_flashcards_from_text(
734
+ username=username,
735
+ text=body,
736
+ deck_name=deck_name,
737
+ target_lang=prefs["native_language"],
738
+ tags=["conversation"],
739
+ )
740
+ st.success(f"Deck exported: {deck_path.name}")
741
+
742
+ if st.button(f"Delete {f.stem}", key=f"delete_{f.stem}"):
743
+ f.unlink()
744
+ st.experimental_rerun()
745
+
746
+ ###############################################################
747
+ # OCR TAB
748
+ ###############################################################
749
+
750
+ def ocr_tab(username: str):
751
+ st.header("OCR → Flashcards")
752
+
753
+ imgs = st.file_uploader("Upload images", ["png", "jpg", "jpeg"], accept_multiple_files=True)
754
+ tgt = st.selectbox("Translate to", ["en", "de", "ja", "zh-cn", "es"])
755
+ deck_name = st.text_input("Deck name", "ocr_vocab")
756
+
757
+ if st.button("Create Deck from OCR"):
758
+ if not imgs:
759
+ st.warning("Upload at least one image.")
760
+ return
761
+
762
+ with st.spinner("Running OCR…"):
763
+ results = ocr_and_translate_batch([f.read() for f in imgs], target_lang=tgt)
764
+
765
+ deck_path = generate_flashcards_from_ocr_results(
766
+ username=username,
767
+ ocr_results=results,
768
+ deck_name=deck_name,
769
+ target_lang=tgt,
770
+ tags=["ocr"],
771
+ )
772
+ st.success(f"Deck saved: {deck_path}")
773
+
774
+
775
+ ###############################################################
776
+ # FLASHCARDS TAB
777
+ ###############################################################
778
+
779
+ def flashcards_tab(username: str):
780
+ import pandas as pd
781
+ import re
782
+
783
+ # ---------------------------------------------------------
784
+ # Helpers
785
+ # ---------------------------------------------------------
786
+
787
+ def normalize(s: str) -> str:
788
+ """lowercase + strip non-alphanumerics for loose grading."""
789
+ s = s.lower()
790
+ s = re.sub(r"[^a-z0-9]+", "", s)
791
+ return s
792
+
793
+ def card_front_html(text: str) -> str:
794
+ return f"""
795
+ <div style="
796
+ background:#1a73e8;
797
+ color:white;
798
+ border-radius:18px;
799
+ padding:50px;
800
+ font-size:2.2rem;
801
+ text-align:center;
802
+ width:70%;
803
+ margin-left:auto;
804
+ margin-right:auto;
805
+ box-shadow:0 4px 12px rgba(0,0,0,0.3);
806
+ ">
807
+ {text}
808
+ </div>
809
+ """
810
+
811
+ def card_back_html(front: str, back: str) -> str:
812
+ return f"""
813
+ <div style="margin-bottom:20px;">
814
+ <div style="
815
+ background:#1a73e8;
816
+ color:white;
817
+ border-radius:18px;
818
+ padding:35px;
819
+ font-size:1.8rem;
820
+ text-align:center;
821
+ width:70%;
822
+ margin-left:auto;
823
+ margin-right:auto;
824
+ box-shadow:0 4px 12px rgba(0,0,0,0.25);
825
+ ">{front}</div>
826
+
827
+ <div style="
828
+ background:#2b2b2b;
829
+ color:#f5f5f5;
830
+ border-radius:18px;
831
+ padding:40px;
832
+ margin-top:18px;
833
+ font-size:2rem;
834
+ text-align:center;
835
+ width:70%;
836
+ margin-left:auto;
837
+ margin-right:auto;
838
+ box-shadow:0 4px 12px rgba(0,0,0,0.25);
839
+ ">{back}</div>
840
+ </div>
841
+ """
842
+
843
+ # ---------------------------------------------------------
844
+ # Load deck
845
+ # ---------------------------------------------------------
846
+
847
+ st.header("Flashcards")
848
+
849
+ decks = list_user_decks(username)
850
+ if not decks:
851
+ st.info("No decks available yet.")
852
+ return
853
+
854
+ deck_name = st.selectbox("Select deck", sorted(decks.keys()))
855
+ deck_path = decks[deck_name]
856
+ deck = load_deck(deck_path)
857
+ cards = deck.get("cards", [])
858
+ tags = deck.get("tags", [])
859
+
860
+ if not cards:
861
+ st.warning("Deck is empty.")
862
+ return
863
+
864
+ st.write(f"Total cards: **{len(cards)}**")
865
+ if tags:
866
+ st.caption("Tags: " + ", ".join(tags))
867
+
868
+ # Delete deck button
869
+ if st.button("Delete deck"):
870
+ deck_path.unlink()
871
+ st.experimental_rerun()
872
+
873
+ # ---------------------------------------------------------
874
+ # Session state setup
875
+ # ---------------------------------------------------------
876
+
877
+ key = f"fc_{deck_name}_"
878
+ ss = st.session_state
879
+
880
+ if key + "init" not in ss:
881
+ ss[key + "mode"] = "Study"
882
+ ss[key + "idx"] = 0
883
+ ss[key + "show_back"] = False
884
+
885
+ # test state
886
+ ss[key + "test_active"] = False
887
+ ss[key + "test_order"] = []
888
+ ss[key + "test_pos"] = 0
889
+ ss[key + "test_results"] = []
890
+
891
+ ss[key + "init"] = True
892
+
893
+ conv = get_conv_manager()
894
+
895
+ mode = st.radio("Mode", ["Study", "Test"], horizontal=True, key=key + "mode")
896
+
897
+ st.markdown("---")
898
+
899
+ # =======================================================
900
+ # CENTER PANEL
901
+ # =======================================================
902
+ with st.container():
903
+
904
+ # ---------------------------------------------------
905
+ # STUDY MODE
906
+ # ---------------------------------------------------
907
+ if mode == "Study":
908
+ idx = ss[key + "idx"] % len(cards)
909
+ card = cards[idx]
910
+ show_back = ss[key + "show_back"]
911
+
912
+ st.markdown("### Study Mode")
913
+ st.markdown("---")
914
+
915
+ # CARD DISPLAY
916
+ if not show_back:
917
+ st.markdown(card_front_html(card["front"]), unsafe_allow_html=True)
918
+ if st.button("🔊 Pronounce", key=key + f"tts_front_{idx}"):
919
+ audio = conv.text_to_speech(card["front"])
920
+ if audio:
921
+ st.audio(audio, format="audio/mp3")
922
+ else:
923
+ st.markdown(card_back_html(card["front"], card["back"]), unsafe_allow_html=True)
924
+ if st.button("🔊 Pronounce", key=key + f"tts_back_{idx}"):
925
+ audio = conv.text_to_speech(card["back"])
926
+ if audio:
927
+ st.audio(audio, format="audio/mp3")
928
+
929
+ # FLIPBOOK CONTROLS
930
+ st.markdown("---")
931
+ c1, c2, c3 = st.columns(3)
932
+ with c1:
933
+ if st.button("Flip", key=key + "flip"):
934
+ ss[key + "show_back"] = not show_back
935
+ st.experimental_rerun()
936
+ with c2:
937
+ if st.button("Shuffle deck", key=key + "shuf"):
938
+ random.shuffle(cards)
939
+ deck["cards"] = cards
940
+ save_deck(deck_path, deck)
941
+ ss[key + "idx"] = 0
942
+ ss[key + "show_back"] = False
943
+ st.experimental_rerun()
944
+ with c3:
945
+ if st.button("Next →", key=key + "next"):
946
+ ss[key + "idx"] = (idx + 1) % len(cards)
947
+ ss[key + "show_back"] = False
948
+ st.experimental_rerun()
949
+
950
+ # DIFFICULTY GRADING (centered)
951
+ st.markdown("### Rate this card")
952
+ cA, cB, cC, cD, cE = st.columns(5)
953
+
954
+ def apply_grade(delta):
955
+ card["score"] = max(0, card.get("score", 0) + delta)
956
+ card["reviews"] = card.get("reviews", 0) + 1
957
+ save_deck(deck_path, deck)
958
+ ss[key + "idx"] = _choose_next_card_index(cards)
959
+ ss[key + "show_back"] = False
960
+ st.experimental_rerun()
961
+
962
+ with cA:
963
+ if st.button("🔥 Very Difficult", key=key+"g_vd"):
964
+ apply_grade(-2)
965
+ with cB:
966
+ if st.button("😣 Hard", key=key+"g_h"):
967
+ apply_grade(-1)
968
+ with cC:
969
+ if st.button("😐 Neutral", key=key+"g_n"):
970
+ apply_grade(0)
971
+ with cD:
972
+ if st.button("🙂 Easy", key=key+"g_e"):
973
+ apply_grade(1)
974
+ with cE:
975
+ if st.button("🏆 Mastered", key=key+"g_m"):
976
+ apply_grade(3)
977
+
978
+ # ---------------------------------------------------
979
+ # TEST MODE
980
+ # ---------------------------------------------------
981
+ else:
982
+ # Initial test setup
983
+ if not ss[key + "test_active"]:
984
+ st.markdown("### Test Setup")
985
+ num_q = st.slider("Number of questions", 3, min(20, len(cards)), min(5, len(cards)), key=key+"nq")
986
+
987
+ if st.button("Start Test", key=key+"begin"):
988
+ order = list(range(len(cards)))
989
+ random.shuffle(order)
990
+ order = order[:num_q]
991
+
992
+ ss[key + "test_active"] = True
993
+ ss[key + "test_order"] = order
994
+ ss[key + "test_pos"] = 0
995
+ ss[key + "test_results"] = []
996
+ st.experimental_rerun()
997
+
998
+ else:
999
+ order = ss[key + "test_order"]
1000
+ pos = ss[key + "test_pos"]
1001
+ results = ss[key + "test_results"]
1002
+
1003
+ # Test Complete
1004
+ if pos >= len(order):
1005
+ correct = sum(r["correct"] for r in results)
1006
+ st.markdown(f"### Test Complete — Score: {correct}/{len(results)} ({correct/len(results)*100:.1f}%)")
1007
+ st.markdown("---")
1008
+
1009
+ for i, r in enumerate(results, 1):
1010
+ emoji = "✅" if r["correct"] else "❌"
1011
+ st.write(f"**{i}.** {r['front']} → expected **{r['back']}**, you answered *{r['user_answer']}* {emoji}")
1012
+
1013
+ if st.button("Restart Test", key=key+"restart"):
1014
+ ss[key + "test_active"] = False
1015
+ ss[key + "test_pos"] = 0
1016
+ ss[key + "test_results"] = []
1017
+ ss[key + "test_order"] = []
1018
+ st.experimental_rerun()
1019
+ return
1020
+
1021
+ # Current question
1022
+ cid = order[pos]
1023
+ card = cards[cid]
1024
+
1025
+ st.progress(pos / len(order))
1026
+ st.caption(f"Question {pos+1} / {len(order)}")
1027
+
1028
+ st.markdown(card_front_html(card["front"]), unsafe_allow_html=True)
1029
+
1030
+ # TTS
1031
+ if st.button("🔊 Pronounce", key=key+f"tts_test_{pos}"):
1032
+ audio = conv.text_to_speech(card["front"])
1033
+ if audio:
1034
+ st.audio(audio, format="audio/mp3")
1035
+
1036
+ user_answer = st.text_input("Your answer:", key=key+f"ans_{pos}")
1037
+
1038
+ if st.button("Submit Answer", key=key+f"submit_{pos}"):
1039
+ ua = user_answer.strip()
1040
+ correct = normalize(ua) == normalize(card["back"])
1041
+
1042
+ # Flash feedback
1043
+ if correct:
1044
+ st.success("Correct!")
1045
+ else:
1046
+ st.error(f"Incorrect — expected: {card['back']}")
1047
+
1048
+ results.append({
1049
+ "front": card["front"],
1050
+ "back": card["back"],
1051
+ "user_answer": ua,
1052
+ "correct": correct,
1053
+ })
1054
+ ss[key + "test_results"] = results
1055
+ ss[key + "test_pos"] = pos + 1
1056
+ st.experimental_rerun()
1057
+
1058
+ # =======================================================
1059
+ # DECK AT A GLANCE (FULL WIDTH)
1060
+ # =======================================================
1061
+ st.markdown("---")
1062
+ st.subheader("Deck at a glance")
1063
+
1064
+ df_rows = []
1065
+ for i, c in enumerate(cards, start=1):
1066
+ df_rows.append({
1067
+ "#": i,
1068
+ "Front": c.get("front", ""),
1069
+ "Back": c.get("back", ""),
1070
+ "Score": c.get("score", 0),
1071
+ "Reviews": c.get("reviews", 0),
1072
+ })
1073
+
1074
+ st.dataframe(pd.DataFrame(df_rows), height=500, use_container_width=True)
1075
+
1076
+
1077
+
1078
+
1079
+ ###############################################################
1080
+ # QUIZ TAB
1081
+ ###############################################################
1082
+
1083
+ def quiz_tab(username: str):
1084
+ st.header("Quiz")
1085
+ ensure_default_decks(username)
1086
+
1087
+ user_dir = get_user_dir(username)
1088
+ quiz_dir = user_dir / "quizzes"
1089
+ quiz_dir.mkdir(exist_ok=True)
1090
+
1091
+ decks = list_user_decks(username)
1092
+ if not decks:
1093
+ st.info("No decks.")
1094
+ return
1095
+
1096
+ selected = st.multiselect("Use decks", sorted(decks.keys()))
1097
+ if not selected:
1098
+ return
1099
+
1100
+ num_q = st.slider("Questions", 3, 20, 6)
1101
+
1102
+ if st.button("Generate quiz"):
1103
+ pool = []
1104
+ for name in selected:
1105
+ pool.extend(load_deck(decks[name])["cards"])
1106
+
1107
+ questions = []
1108
+ for _ in range(num_q):
1109
+ c = random.choice(pool)
1110
+ qtype = random.choice(["mc", "fill"])
1111
+ if qtype == "mc":
1112
+ others = random.sample(pool, min(3, len(pool) - 1))
1113
+ opts = [c["back"]] + [x["back"] for x in others]
1114
+ random.shuffle(opts)
1115
+ questions.append(
1116
+ {"type": "mc", "prompt": c["front"], "options": opts, "answer": c["back"]}
1117
+ )
1118
+ else:
1119
+ questions.append(
1120
+ {"type": "fill", "prompt": c["front"], "answer": c["back"]}
1121
+ )
1122
+
1123
+ qid = datetime.utcnow().strftime("quiz_%Y%m%d_%H%M%S")
1124
+ quiz = {"id": qid, "questions": questions}
1125
+ (quiz_dir / f"{qid}.json").write_text(json.dumps(quiz, indent=2))
1126
+
1127
+ st.session_state["quiz"] = quiz
1128
+ st.session_state["quiz_idx"] = 0
1129
+ st.session_state["quiz_answers"] = {}
1130
+ st.success("Quiz created!")
1131
+
1132
+ if "quiz" not in st.session_state:
1133
+ return
1134
+
1135
+ quiz = st.session_state["quiz"]
1136
+ qs = quiz["questions"]
1137
+ idx = st.session_state["quiz_idx"]
1138
+
1139
+ if idx >= len(qs):
1140
+ correct = sum(1 for v in st.session_state["quiz_answers"].values() if v["correct"])
1141
+ st.success(f"Score: {correct}/{len(qs)}")
1142
+ if st.button("New quiz"):
1143
+ del st.session_state["quiz"]
1144
+ del st.session_state["quiz_idx"]
1145
+ del st.session_state["quiz_answers"]
1146
+ return
1147
+
1148
+ q = qs[idx]
1149
+ st.subheader(f"Question {idx+1}/{len(qs)}")
1150
+ st.markdown(f"**{q['prompt']}**")
1151
+
1152
+ if q["type"] == "mc":
1153
+ choice = st.radio("Choose:", q["options"], key=f"mc_{idx}")
1154
+ if st.button("Submit", key=f"sub_{idx}"):
1155
+ st.session_state["quiz_answers"][idx] = {
1156
+ "given": choice,
1157
+ "correct": choice == q["answer"],
1158
+ }
1159
+ st.session_state["quiz_idx"] += 1
1160
+ st.experimental_rerun()
1161
+ else:
1162
+ ans = st.text_input("Your answer", key=f"fill_{idx}")
1163
+ if st.button("Submit", key=f"sub_{idx}"):
1164
+ st.session_state["quiz_answers"][idx] = {
1165
+ "given": ans,
1166
+ "correct": ans.strip().lower() == q["answer"].lower(),
1167
+ }
1168
+ st.session_state["quiz_idx"] += 1
1169
+ st.experimental_rerun()
1170
+
1171
+
1172
+ ###############################################################
1173
+ # MAIN
1174
+ ###############################################################
1175
+
1176
+ def main():
1177
+ # ---------- AUTH ----------
1178
+ if "user" not in st.session_state:
1179
+ login_view()
1180
+ return
1181
+
1182
+ username = st.session_state["user"]
1183
+ st.sidebar.write(f"Logged in as **{username}**")
1184
+
1185
+ if st.sidebar.button("Log out"):
1186
+ st.session_state.clear()
1187
+ st.experimental_rerun()
1188
+
1189
+ # ---------- LOAD MODELS + PREFS ----------
1190
+ preload_models()
1191
+ sidebar_settings(username)
1192
+ ensure_default_decks(username)
1193
+
1194
+ # ---------- TAB PERSISTENCE ----------
1195
+ if "active_tab" not in st.session_state:
1196
+ st.session_state["active_tab"] = 0
1197
+
1198
+ tab_labels = ["Dashboard", "Conversation", "OCR", "Flashcards", "Quiz", "Settings"]
1199
+ tabs = st.tabs(tab_labels)
1200
+
1201
+ tab_dash, tab_conv, tab_ocr, tab_flash, tab_quiz, tab_settings = tabs
1202
+
1203
+ # restore active tab (required so Streamlit shows correct tab on rerun)
1204
+ _ = tabs[st.session_state["active_tab"]]
1205
+
1206
+ with tab_dash:
1207
+ st.session_state["active_tab"] = 0
1208
+ dashboard_tab(username)
1209
+
1210
+ with tab_conv:
1211
+ st.session_state["active_tab"] = 1
1212
+ conversation_tab(username)
1213
+
1214
+ with tab_ocr:
1215
+ st.session_state["active_tab"] = 2
1216
+ ocr_tab(username)
1217
+
1218
+ with tab_flash:
1219
+ st.session_state["active_tab"] = 3
1220
+ flashcards_tab(username)
1221
+
1222
+ with tab_quiz:
1223
+ st.session_state["active_tab"] = 4
1224
+ quiz_tab(username)
1225
+
1226
+ with tab_settings:
1227
+ st.session_state["active_tab"] = 5
1228
+ settings_tab(username)
1229
+
1230
+
1231
+
1232
+ if __name__ == "__main__":
1233
+ main()
1234
+
1235
+
src/app/ocr_tools.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/app/ocr_tools.py
3
+
4
+ import io
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from PIL import Image
8
+ import pytesseract
9
+ from deep_translator import GoogleTranslator
10
+
11
+
12
+ def _simple_ocr(image_bytes: bytes) -> str:
13
+ """
14
+ Fallback OCR using pytesseract.
15
+ """
16
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
17
+ text = pytesseract.image_to_string(img)
18
+ return text.strip()
19
+
20
+
21
+ def ocr_and_translate_batch(
22
+ images: List[bytes],
23
+ target_lang: str = "en",
24
+ prefer_ocr_local: bool = True,
25
+ ) -> List[Dict]:
26
+ """
27
+ Runs OCR on a batch of images. For now, we always use the
28
+ simple pytesseract-based OCR, but the 'prefer_ocr_local'
29
+ flag is kept for compatibility with previous versions that
30
+ used a local PaddleOCR pipeline.
31
+
32
+ Returns: list of dicts with keys:
33
+ - "text": original OCR text
34
+ - "translation": translation into target_lang
35
+ - "target_lang": target_lang
36
+ """
37
+ translator = GoogleTranslator(source="auto", target=target_lang)
38
+
39
+ results: List[Dict] = []
40
+ for img_bytes in images:
41
+ text = _simple_ocr(img_bytes)
42
+ if text:
43
+ try:
44
+ translated = translator.translate(text)
45
+ except Exception:
46
+ translated = ""
47
+
48
+ results.append(
49
+ {
50
+ "text": text,
51
+ "translation": translated,
52
+ "target_lang": target_lang,
53
+ }
54
+ )
55
+ else:
56
+ results.append(
57
+ {
58
+ "text": "",
59
+ "translation": "",
60
+ "target_lang": target_lang,
61
+ }
62
+ )
63
+ return results
64
+
src/app/quiz_tools.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/app/quiz_tools.py
3
+
4
+ # Placeholder restored because modifications moved to main_app.
5
+ # This keeps the file present so import does not fail.
6
+
7
+ import json
8
+ import random
9
+ from datetime import datetime
10
+
11
+ def create_semantic_quiz_for_user(username: str, topic: str, num_questions: int = 5):
12
+ reading_passages = [
13
+ f"{topic.capitalize()} is important in daily life. Many people enjoy talking about it.",
14
+ f"Here is a short story based on the topic '{topic}'.",
15
+ f"In this short description, you will learn more about {topic}.",
16
+ ]
17
+
18
+ questions = []
19
+ for i in range(num_questions):
20
+ passage = random.choice(reading_passages)
21
+ q_type = random.choice(["translate_phrase", "summarize", "interpret"])
22
+
23
+ if q_type == "translate_phrase":
24
+ questions.append({
25
+ "type": "semantic_translate_phrase",
26
+ "prompt": f"Translate:
27
+
28
+ '{passage}'",
29
+ "answer": "(model evaluated)",
30
+ "explanation": f"Checks ability to translate topic '{topic}'."
31
+ })
32
+ elif q_type == "summarize":
33
+ questions.append({
34
+ "type": "semantic_summarize",
35
+ "prompt": f"Summarize:
36
+
37
+ {passage}",
38
+ "answer": "(model evaluated)",
39
+ "explanation": f"Checks comprehension of topic '{topic}'."
40
+ })
41
+ elif q_type == "interpret":
42
+ questions.append({
43
+ "type": "semantic_interpret",
44
+ "prompt": f"Interpret meaning:
45
+
46
+ {passage}",
47
+ "answer": "(model evaluated)",
48
+ "explanation": f"Checks conceptual understanding of '{topic}'."
49
+ })
50
+
51
+ ts = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ")
52
+ quiz_id = f"semantic_quiz_{ts}"
53
+
54
+ return {
55
+ "id": quiz_id,
56
+ "created_at": ts,
57
+ "topic": topic,
58
+ "questions": questions,
59
+ }
src/app/viewers.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/app/viewers.py
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Dict, List
7
+
8
+ from app.config import get_user_dir
9
+ from app.flashcards_tools import load_deck
10
+
11
+
12
+ def _build_flipbook_html(deck_name: str, cards: List[Dict]) -> str:
13
+ """
14
+ Builds a simple HTML+JS flip-style viewer for a deck of cards.
15
+ Front = card['front'], Back = card['back'].
16
+ """
17
+ js_cards = json.dumps(
18
+ [
19
+ {"front": c.get("front", ""), "back": c.get("back", "")}
20
+ for c in cards
21
+ ],
22
+ ensure_ascii=False,
23
+ )
24
+
25
+ html = f"""<!DOCTYPE html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="UTF-8" />
29
+ <title>Flashcards — {deck_name}</title>
30
+ <style>
31
+ body {{
32
+ background: #111;
33
+ color: #eee;
34
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
35
+ padding: 1.5rem;
36
+ }}
37
+
38
+ .wrapper {{
39
+ max-width: 700px;
40
+ margin: 0 auto;
41
+ }}
42
+
43
+ h1 {{
44
+ text-align: center;
45
+ margin-bottom: 1rem;
46
+ }}
47
+
48
+ .card-container {{
49
+ perspective: 1000px;
50
+ margin-bottom: 1rem;
51
+ }}
52
+
53
+ .card {{
54
+ position: relative;
55
+ width: 100%;
56
+ height: 250px;
57
+ border-radius: 16px;
58
+ background: #222;
59
+ box-shadow: 0 8px 15px rgba(0,0,0,0.4);
60
+ transition: transform 0.6s;
61
+ transform-style: preserve-3d;
62
+ cursor: pointer;
63
+ }}
64
+
65
+ .card.flipped {{
66
+ transform: rotateY(180deg);
67
+ }}
68
+
69
+ .card-face {{
70
+ position: absolute;
71
+ inset: 0;
72
+ border-radius: 16px;
73
+ backface-visibility: hidden;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ padding: 1rem;
78
+ }}
79
+
80
+ .card-face.front {{
81
+ background: #333;
82
+ }}
83
+
84
+ .card-face.back {{
85
+ background: #1a73e8;
86
+ transform: rotateY(180deg);
87
+ }}
88
+
89
+ .card-text {{
90
+ font-size: 2.2rem;
91
+ text-align: center;
92
+ word-wrap: break-word;
93
+ }}
94
+
95
+ .controls {{
96
+ display: flex;
97
+ justify-content: center;
98
+ gap: 0.75rem;
99
+ margin-bottom: 0.5rem;
100
+ }}
101
+
102
+ button {{
103
+ background: #333;
104
+ color: #eee;
105
+ border-radius: 999px;
106
+ border: none;
107
+ padding: 0.5rem 1rem;
108
+ font-size: 0.95rem;
109
+ cursor: pointer;
110
+ transition: background 0.2s ease, transform 0.1s ease;
111
+ }}
112
+
113
+ button:hover {{
114
+ background: #444;
115
+ transform: translateY(-1px);
116
+ }}
117
+
118
+ .meta {{
119
+ text-align: center;
120
+ margin-top: 0.25rem;
121
+ font-size: 0.9rem;
122
+ color: #ccc;
123
+ }}
124
+
125
+ .badge {{
126
+ display: inline-block;
127
+ padding: 0.1rem 0.75rem;
128
+ border-radius: 999px;
129
+ font-size: 0.8rem;
130
+ background: #555;
131
+ margin-left: 0.5rem;
132
+ }}
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <div class="wrapper">
137
+ <h1>{deck_name}</h1>
138
+ <div class="card-container">
139
+ <div class="card" id="card">
140
+ <div class="card-face front">
141
+ <div class="card-text" id="cardFront"></div>
142
+ </div>
143
+ <div class="card-face back">
144
+ <div class="card-text" id="cardBack"></div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="controls">
150
+ <button id="prevBtn">⏮ Prev</button>
151
+ <button id="flipBtn">🔁 Flip</button>
152
+ <button id="nextBtn">Next ⏭</button>
153
+ <button id="shuffleBtn">🔀 Shuffle</button>
154
+ </div>
155
+
156
+ <div class="meta">
157
+ <span id="cardIndex">Card 0 / 0</span>
158
+ <span class="badge" id="sideLabel">Front</span>
159
+ </div>
160
+ </div>
161
+
162
+ <script>
163
+ const cards = {js_cards};
164
+ let currentIndex = 0;
165
+ let isFlipped = false;
166
+
167
+ const cardEl = document.getElementById('card');
168
+ const frontEl = document.getElementById('cardFront');
169
+ const backEl = document.getElementById('cardBack');
170
+ const indexEl = document.getElementById('cardIndex');
171
+ const sideLabelEl = document.getElementById('sideLabel');
172
+
173
+ function renderCard() {{
174
+ if (!cards.length) {{
175
+ frontEl.textContent = "(No cards)";
176
+ backEl.textContent = "";
177
+ indexEl.textContent = "Card 0 / 0";
178
+ sideLabelEl.textContent = "Front";
179
+ cardEl.classList.remove('flipped');
180
+ return;
181
+ }}
182
+
183
+ const card = cards[currentIndex];
184
+ frontEl.textContent = card.front || "";
185
+ backEl.textContent = card.back || "";
186
+
187
+ indexEl.textContent = "Card " + (currentIndex + 1) + " / " + cards.length;
188
+ sideLabelEl.textContent = isFlipped ? "Back" : "Front";
189
+ }}
190
+
191
+ function flipCard() {{
192
+ if (!cards.length) return;
193
+ isFlipped = !isFlipped;
194
+ if (isFlipped) {{
195
+ cardEl.classList.add('flipped');
196
+ }} else {{
197
+ cardEl.classList.remove('flipped');
198
+ }}
199
+ sideLabelEl.textContent = isFlipped ? "Back" : "Front";
200
+ }}
201
+
202
+ function nextCard() {{
203
+ if (!cards.length) return;
204
+ currentIndex = (currentIndex + 1) % cards.length;
205
+ isFlipped = false;
206
+ cardEl.classList.remove('flipped');
207
+ renderCard();
208
+ }}
209
+
210
+ function prevCard() {{
211
+ if (!cards.length) return;
212
+ currentIndex = (currentIndex - 1 + cards.length) % cards.length;
213
+ isFlipped = false;
214
+ cardEl.classList.remove('flipped');
215
+ renderCard();
216
+ }}
217
+
218
+ function shuffleCards() {{
219
+ for (let i = cards.length - 1; i > 0; i--) {{
220
+ const j = Math.floor(Math.random() * (i + 1));
221
+ [cards[i], cards[j]] = [cards[j], cards[i]];
222
+ }}
223
+ currentIndex = 0;
224
+ isFlipped = false;
225
+ cardEl.classList.remove('flipped');
226
+ renderCard();
227
+ }}
228
+
229
+ cardEl.addEventListener('click', flipCard);
230
+ document.getElementById('flipBtn').addEventListener('click', flipCard);
231
+ document.getElementById('nextBtn').addEventListener('click', nextCard);
232
+ document.getElementById('prevBtn').addEventListener('click', prevCard);
233
+ document.getElementById('shuffleBtn').addEventListener('click', shuffleCards);
234
+
235
+ document.addEventListener('keydown', (e) => {{
236
+ if (!cards.length) return;
237
+ if (e.code === "ArrowRight") nextCard();
238
+ else if (e.code === "ArrowLeft") prevCard();
239
+ else if (e.code === "Space") {{
240
+ e.preventDefault();
241
+ flipCard();
242
+ }}
243
+ }});
244
+
245
+ renderCard();
246
+ </script>
247
+ </body>
248
+ </html>
249
+ """
250
+ return html
251
+
252
+
253
+ def generate_flashcard_viewer_for_user(username: str, deck_path: Path) -> Path:
254
+ """
255
+ Generates an HTML flipbook viewer for the given deck in the user's
256
+ /viewers directory, and returns the path to the HTML file.
257
+ """
258
+ deck = load_deck(deck_path)
259
+ deck_name = deck.get("name", deck_path.stem)
260
+ cards = deck.get("cards", [])
261
+
262
+ html_str = _build_flipbook_html(deck_name, cards)
263
+
264
+ user_dir = get_user_dir(username)
265
+ viewer_dir = user_dir / "viewers"
266
+ viewer_dir.mkdir(parents=True, exist_ok=True)
267
+
268
+ safe_name = deck_path.stem
269
+ out_path = viewer_dir / f"{safe_name}_viewer.html"
270
+ out_path.write_text(html_str, encoding="utf-8")
271
+ return out_path
272
+
src/generate_flashcard_viewer.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # src/generate_flashcard_viewer.py
3
+
4
+ """
5
+ Utility script to generate a standalone HTML viewer for a given
6
+ user's flashcard deck. Typically, the Streamlit app calls
7
+ app.viewers.generate_flashcard_viewer_for_user directly.
8
+ """
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ from app.viewers import generate_flashcard_viewer_for_user
14
+
15
+
16
+ def main():
17
+ parser = argparse.ArgumentParser()
18
+ parser.add_argument("--user", required=True, help="Username")
19
+ parser.add_argument("--deck", required=True, help="Path to deck JSON")
20
+ args = parser.parse_args()
21
+
22
+ deck_path = Path(args.deck)
23
+ if not deck_path.exists():
24
+ raise FileNotFoundError(deck_path)
25
+
26
+ out_path = generate_flashcard_viewer_for_user(args.user, deck_path)
27
+ print(f"Viewer written to: {out_path}")
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
32
+
src/generate_quiz.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+ print("Deprecated — use quiz inside Streamlit UI")
src/generate_quiz_viewer.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+
2
+ print("Viewer now handled by app.viewers")