'
)
def _safe_attr(s: str) -> str:
"""Escape a string for use inside an HTML attribute value."""
return (s or "").replace("&", "&").replace('"', """).replace("'", "'").replace("\n", " ")
def _safe_html(s: str) -> str:
"""Escape a string for use as HTML text content."""
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">")
def _render_sidebar_html(user_id: str) -> str:
"""Build the full collapsible lesson-browser sidebar HTML."""
if not user_id:
return (
'
'
'Sign in to see your lessons.
'
)
try:
pages = nb.list_pages(user_id)
except Exception as exc:
return f'
⚠ Could not load lessons: {exc}
'
# Resource-only pages (link/book lists) live in the Resources tab, not the lecture browser.
pages = [p for p in pages if p.get("page_type") != "resource"]
if not pages:
return (
'
'
'No lessons saved yet. Paste French text above and click 💾 Save.
'
)
import nlp as _nlp
def _lesson_item(p: dict, extra_style: str = "") -> str:
pid = p["id"]
title = (p.get("title") or "Untitled")
date = p.get("date", "")
prev = _safe_attr(p.get("preview", ""))
t_safe = _safe_attr(title)
short = title[:42] + ("…" if len(title) > 42 else "")
return (
f'
'
f'
{short}
'
f'
{date}
'
f'
'
)
def _group_header(label: str, count: int, open_attr: str, items: str) -> str:
return (
f''
f''
f'{label} ({count})'
f''
f'{items}'
f''
)
# ── By Date — grouped into collapsible per-date sections ──────────────────
date_groups = _nlp.group_by_date(pages)
date_html = ""
for i, (d, d_pages) in enumerate(date_groups.items()):
items = "".join(_lesson_item(p) for p in d_pages)
date_html += _group_header(_nlp.format_date_header(d), len(d_pages), "open" if i == 0 else "", items)
# ── By Topic ─────────────────────────────────────────────────────────────
grouped = _nlp.get_lesson_categories(pages)
topic_html = ""
for cat, cat_pages in grouped.items():
items = "".join(_lesson_item(p) for p in cat_pages)
topic_html += _group_header(cat, len(cat_pages), "", items)
return (
f'
'
# Search box
f'
'
f''
f'
'
# By Date section (open by default)
f''
f''
f'📅 By Date ({len(pages)})'
f''
f'
{date_html}
'
f''
# By Topic section (collapsed by default)
f''
f''
f'🏷️ By Topic'
f''
f'
{topic_html}
'
f''
# Hover preview tooltip (positioned by JS)
f''
f'
'
)
def _domain(url: str) -> str:
import urllib.parse
try:
netloc = urllib.parse.urlparse(url).netloc
return netloc[4:] if netloc.startswith("www.") else netloc
except ValueError:
return url
def _render_resources_html(user_id: str) -> str:
"""Build a beautiful card layout for link/book resources pulled out of the notebook."""
if not user_id:
return '
Sign in to see your resources.
'
try:
pages = nb.list_resources(user_id)
except Exception as exc:
return f'
⚠ Could not load resources: {exc}
'
pages = [p for p in pages if p.get("links") or p.get("books")]
if not pages:
return (
'
'
'No resources yet. Save a page that\'s mostly links or book recommendations '
'(e.g. "Online Resources", "Books to Read") and it\'ll show up here, '
'beautifully laid out and out of your lecture notes.
'
)
sections = ""
for page in pages:
title = _safe_html(page.get("title") or "Resources")
cards = ""
for link in page.get("links") or []:
url = link.get("url", "")
label = _safe_html(link.get("label") or url)
domain = _domain(url)
cards += (
f''
f''
f''
f'{label}'
f'{_safe_html(domain)}'
f''
)
books = ""
for book in page.get("books") or []:
b_title = _safe_html(book.get("title", ""))
meta = " · ".join(x for x in [book.get("author", ""), book.get("note", "")] if x)
books += (
f'
'
f'📖'
f'{b_title}'
+ (f'{_safe_html(meta)}' if meta else "")
+ '
'
)
body = ""
if cards:
body += f'
{cards}
'
if books:
body += f'
{books}
'
sections += f'
📚 {title}
{body}
'
return f'
{sections}
'
def _page_btns_hidden():
"""Return gr.update calls to hide update/delete buttons and confirm row."""
return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
def _page_btns_visible():
"""Return gr.update calls to show update/delete buttons, hide confirm row."""
return gr.update(visible=True), gr.update(visible=True), gr.update(visible=False)
def save_page_handler(text: str, ann_json: str, user_id: str):
if not user_id:
return "Please sign in first.", _render_sidebar_html(user_id), None, "", gr.update(visible=False), *_page_btns_hidden()
if not text.strip():
return "Nothing to save — type or paste some French text first.", _render_sidebar_html(user_id), None, "", gr.update(visible=False), *_page_btns_hidden()
try:
ann = json.loads(ann_json) if ann_json else {}
page_id, title = nb.save_page(user_id, text, ann)
gamify.add_points(user_id, "saved_lesson")
return f"✅ Saved as **{title}**", _render_sidebar_html(user_id), page_id, title, gr.update(visible=True), *_page_btns_visible()
except Exception as e:
return f"⚠ Could not save: {e}", _render_sidebar_html(user_id), None, "", gr.update(visible=False), *_page_btns_hidden()
def load_pages_list(user_id: str):
return _render_sidebar_html(user_id)
def load_page_handler(page_id: str, colors_on: bool, user_id: str):
if not page_id or not user_id:
return "", "", "", None, "", gr.update(visible=False), *_page_btns_hidden()
try:
page = nb.get_page(page_id, user_id)
if not page:
return "", "", "", None, "", gr.update(visible=False), *_page_btns_hidden()
ann = page.get("annotations") or {}
if isinstance(ann, str):
ann = json.loads(ann)
html = nlp.render_html(ann, colors_on)
return (page["raw_text"], html, json.dumps(ann, ensure_ascii=False), page_id,
page["title"], gr.update(visible=True), *_page_btns_visible())
except Exception as e:
return "", f"⚠ Could not load page: {e}", "", None, "", gr.update(visible=False), *_page_btns_hidden()
def update_page_handler(text: str, ann_json: str, page_id: str, user_id: str):
if not page_id or not user_id:
return "⚠ No page loaded to update.", _render_sidebar_html(user_id)
if not text.strip():
return "Nothing to save.", _render_sidebar_html(user_id)
try:
ann = json.loads(ann_json) if ann_json else {}
title = nb.update_page(page_id, user_id, text, ann)
return f"✅ Updated **{title}**", _render_sidebar_html(user_id)
except Exception as e:
return f"⚠ Could not update: {e}", _render_sidebar_html(user_id)
def rename_page_handler(title: str, page_id: str, user_id: str):
if not page_id or not user_id:
return "⚠ No page loaded to rename.", _render_sidebar_html(user_id)
if not title.strip():
return "⚠ Title can't be empty.", _render_sidebar_html(user_id)
try:
new_title = nb.update_title(page_id, user_id, title)
return f"✅ Renamed to **{new_title}**", _render_sidebar_html(user_id)
except Exception as e:
return f"⚠ Could not rename: {e}", _render_sidebar_html(user_id)
def delete_page_handler(page_id: str, user_id: str):
if not page_id or not user_id:
return "⚠ No page selected.", _render_sidebar_html(user_id), "", "", "", None, "", gr.update(visible=False), *_page_btns_hidden()
try:
nb.delete_page(page_id, user_id)
return (
"🗑️ Page deleted.",
_render_sidebar_html(user_id),
"", "", "",
None,
"", gr.update(visible=False),
*_page_btns_hidden(),
)
except Exception as e:
return (f"⚠ Could not delete: {e}", _render_sidebar_html(user_id), "", "", "", page_id,
gr.update(), gr.update(), *_page_btns_visible())
# ── Chat tab handlers ─────────────────────────────────────────────────────────
def chat_fn(message: str, history: list, user_id: str, lesson_text: str):
if not user_id:
yield history + [
{"role": "user", "content": message},
{"role": "assistant", "content": "Please sign in to chat with your French coach."},
]
return
if not message.strip():
yield history
return
system = prompts.CHAT_SYSTEM
if lesson_text and lesson_text.strip():
system += f"\n\nCurrent lesson context:\n{lesson_text[:500]}"
messages = [{"role": "system", "content": system}]
for item in history:
if isinstance(item, dict):
messages.append({"role": item["role"], "content": item["content"]})
elif isinstance(item, (list, tuple)) and len(item) == 2:
if item[0]:
messages.append({"role": "user", "content": item[0]})
if item[1]:
messages.append({"role": "assistant", "content": item[1]})
messages.append({"role": "user", "content": message})
history = history + [
{"role": "user", "content": message},
{"role": "assistant", "content": ""},
]
for chunk in llm.chat(messages, stream=True, max_tokens=600):
history[-1]["content"] += chunk
yield history
# ── Text exercise handlers ────────────────────────────────────────────────────
def gen_text_exercise(lesson_text: str, user_id: str):
if not user_id:
return _login_prompt(), gr.State("")
exercise = ex.generate_text_exercise(lesson_text, user_id)
return ex.render_text_exercise(exercise), json.dumps(exercise)
def check_text_answer(user_answer: str, exercise_json: str, user_id: str):
if not exercise_json:
return ""
exercise = json.loads(exercise_json)
answer = exercise.get("answer", "").strip().lower()
correct = user_answer.strip().lower() == answer
if user_id:
gamify.add_points(user_id, "exercise_done")
return ex.render_exercise_feedback(correct, exercise.get("answer",""), exercise.get("explanation",""))
# ── Dialogue handlers ─────────────────────────────────────────────────────────
def gen_dialogue(lesson_text: str, user_id: str):
if not user_id:
return _login_prompt(), "", "{}"
dialogue = ex.generate_dialogue(lesson_text, user_id)
state = {"dialogue": dialogue, "replies": []}
hint = ex.get_next_user_hint(dialogue, 0)
transcript = ex.render_dialogue(dialogue, [])
return transcript, f"💬 Your turn: *{hint}*", json.dumps(state)
def send_dialogue_reply(reply: str, state_json: str, user_id: str):
if not reply.strip() or not state_json or not user_id:
return "", "", state_json, ""
state = json.loads(state_json)
dialogue = state["dialogue"]
replies = state["replies"]
hint = ex.get_next_user_hint(dialogue, len(replies))
fb_data = ex.dialogue_feedback(reply, hint, dialogue.get("scene",""), user_id)
feedback = fb_data.get("feedback","")
natural = fb_data.get("natural_version","")
fb_html = (
f'