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