| import base64 |
| import html |
| import json |
| import math |
| import os |
| import random |
| import re |
| import uuid |
| import wave |
| from dataclasses import dataclass, asdict |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
|
|
| import gradio as gr |
| import requests |
|
|
| try: |
| from gradio_client import Client as HFSpaceClient |
| except Exception: |
| HFSpaceClient = None |
|
|
| try: |
| import spaces |
| except Exception: |
| class _SpacesFallback: |
| @staticmethod |
| def GPU(fn): |
| return fn |
|
|
| spaces = _SpacesFallback() |
|
|
| try: |
| from pypdf import PdfReader |
| except Exception: |
| PdfReader = None |
|
|
| try: |
| import pypdfium2 as pdfium |
| except Exception: |
| pdfium = None |
|
|
|
|
| APP_DIR = Path(__file__).parent.resolve() |
| TMP_DIR = APP_DIR / "tmp_outputs" |
| TMP_DIR.mkdir(exist_ok=True) |
|
|
| def _load_dotenv_file(dotenv_path: Path) -> None: |
| if not dotenv_path.exists(): |
| return |
| for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines(): |
| line = raw_line.strip() |
| if not line or line.startswith("#") or "=" not in line: |
| continue |
| key, value = line.split("=", 1) |
| key = key.strip() |
| value = value.strip().strip('"').strip("'") |
| if key and key not in os.environ: |
| os.environ[key] = value |
|
|
|
|
| _load_dotenv_file(APP_DIR / ".env") |
|
|
| API_URL = os.getenv("API_URL") or os.getenv("API_UR", "") |
| API_KEY = os.getenv("API_KEY", "") |
| USE_MOCK_MODELS = os.getenv("USE_MOCK_MODELS", "0" if (API_URL and API_KEY) else "1") == "1" |
| USE_MOCK_TTS = os.getenv("USE_MOCK_TTS", "0") == "1" |
| CHAT_MODEL_ID = os.getenv("QWEN_VL_MODEL_ID", "gpt-4.1") |
| TTS_MODEL_ID = os.getenv("QWEN_TTS_MODEL_ID", "qwen-tts") |
| TTS_SPEAKER = os.getenv("QWEN_TTS_SPEAKER", "longxiaochun_v2") |
| TTS_FORMAT = os.getenv("QWEN_TTS_FORMAT", "wav") |
| HF_TTS_SPACE_ID = os.getenv("HF_TTS_SPACE_ID", "").strip() |
| HF_TTS_SPACE_URL = os.getenv("HF_TTS_SPACE_URL", "").strip() |
| _hf_tts_api_name_raw = (os.getenv("HF_TTS_API_NAME", "//tts_chunk") or "").strip() |
| if not _hf_tts_api_name_raw: |
| HF_TTS_API_NAME = "//tts_chunk" |
| elif _hf_tts_api_name_raw.startswith("//"): |
| HF_TTS_API_NAME = _hf_tts_api_name_raw |
| else: |
| HF_TTS_API_NAME = f"/{_hf_tts_api_name_raw.lstrip('/')}" |
| HF_TTS_VOICE = os.getenv("HF_TTS_VOICE", "male") |
| HF_TTS_LANGUAGE = os.getenv("HF_TTS_LANGUAGE", "Chinese") |
| HF_TTS_ALLOW_FALLBACK = os.getenv("HF_TTS_ALLOW_FALLBACK", "1") == "1" |
| TEXT_SPLIT_TO_CHUNK = (os.getenv("TEXT_SPLIT_TO_CHUNK", "0") or "").strip().lower() in {"1", "true", "yes", "y"} |
| HF_TOKEN = ( |
| os.getenv("HF_TOKEN") |
| or os.getenv("HUGGINGFACEHUB_API_TOKEN") |
| or os.getenv("HF_API_TOKEN", "") |
| ) |
| API_TIMEOUT_SEC = int(os.getenv("API_TIMEOUT_SEC", "180")) |
| QWEN_VL_MAX_PAGES = int(os.getenv("QWEN_VL_MAX_PAGES", "4")) |
| QWEN_VL_RENDER_SCALE = float(os.getenv("QWEN_VL_RENDER_SCALE", "1.5")) |
| QWEN_VL_MAX_NEW_TOKENS = int(os.getenv("QWEN_VL_MAX_NEW_TOKENS", "800")) |
| QWEN_VL_MCQ_MAX_NEW_TOKENS = int(os.getenv("QWEN_VL_MCQ_MAX_NEW_TOKENS", "1800")) |
|
|
|
|
| DEFAULT_LECTURE_PROMPT_TEMPLATE = """ |
| You are a teaching assistant. Read the uploaded paper content and produce a clear lecture-style explanation in English: |
| 1. Explain the problem and background first; |
| 2. Explain the core method step by step / module by module; |
| 3. Summarize key experiments and highlights; |
| 4. End with limitations and suitable use cases; |
| 5. Keep it classroom-friendly (about 400-700 words). |
| |
| Paper content (may be excerpted): |
| {document} |
| """.strip() |
|
|
|
|
| DEFAULT_MCQ_PROMPT_TEMPLATE = """ |
| Based on the paper content below, generate 5 English single-choice MCQs for a classroom quiz. |
| Output strict JSON only (no markdown code block), in this format: |
| {{ |
| "questions": [ |
| {{ |
| "question": "...", |
| "options": ["Option A", "Option B", "Option C", "Option D"], |
| "answer": "A", |
| "explanation": "..." |
| }} |
| ] |
| }} |
| |
| Requirements: |
| 1. Exactly 5 questions; |
| 2. 4 options per question; |
| 3. `answer` must be one of A/B/C/D; |
| 4. Explanation should tell why it is correct and common mistakes; |
| 5. Cover background, method, experiments/results, and limitations. |
| |
| Paper content (may be excerpted): |
| {document} |
| """.strip() |
|
|
|
|
| DEFAULT_MCQ_RETRY_PROMPT_TEMPLATE = """ |
| Generate 5 English single-choice MCQs from the following paper content. |
| Output valid JSON only. No explanation outside JSON, no markdown. |
| |
| Constraints: |
| 1. Compact JSON (single line is fine); |
| 2. Exactly 5 questions; |
| 3. Each question includes `question`, `options` (4 items), `answer` (A/B/C/D), `explanation`; |
| 4. Keep explanations short (1-2 sentences); |
| 5. If uncertain, still generate based on the paper content only. |
| |
| Output format: |
| {{"questions":[{{"question":"...","options":["...","...","...","..."],"answer":"A","explanation":"..."}}]}} |
| |
| Paper content: |
| {document} |
| """.strip() |
|
|
|
|
| CHARACTERS_DIR = APP_DIR / "characters" |
|
|
|
|
| def _read_text_if_exists(path: Path, fallback: str) -> str: |
| try: |
| return path.read_text(encoding="utf-8").strip() |
| except Exception: |
| return fallback |
|
|
|
|
| def render_prompt_template(template: str, document: str, replacements: Optional[Dict[str, str]] = None) -> str: |
| |
| s = str(template) |
| s = s.replace("{document}", document).replace("{paper_text}", document) |
| if replacements: |
| for k, v in replacements.items(): |
| s = s.replace("{" + str(k) + "}", str(v)) |
| return s |
|
|
|
|
| def load_character_configs() -> Dict[str, Dict[str, Any]]: |
| configs: Dict[str, Dict[str, Any]] = {} |
| if CHARACTERS_DIR.exists(): |
| for d in sorted(CHARACTERS_DIR.iterdir()): |
| if not d.is_dir(): |
| continue |
| meta_path = d / "meta.json" |
| meta: Dict[str, Any] = {} |
| if meta_path.exists(): |
| try: |
| parsed = json.loads(meta_path.read_text(encoding="utf-8")) |
| if isinstance(parsed, dict): |
| meta = parsed |
| except Exception: |
| meta = {} |
| cid = str(meta.get("id") or d.name) |
| if cid in configs: |
| cid = d.name |
| avatar_rel = str(meta.get("avatar", "avatar.jpg")) |
| lecture_prompt_path = d / "lecture_prompt.txt" |
| mcq_prompt_path = d / "mcq_prompt.txt" |
| mcq_retry_prompt_path = d / "mcq_retry_prompt.txt" |
| feedback_prompt_path = d / "feedback.txt" |
| config: Dict[str, Any] = { |
| "id": cid, |
| "display_name": str(meta.get("display_name", d.name)), |
| "tagline": str(meta.get("tagline", "Research paper explainer · MCQ coach")), |
| "byline": str(meta.get("byline", "By @local-demo")), |
| "chat_label": str(meta.get("chat_label", meta.get("display_name", d.name))), |
| "chat_mode": str(meta.get("chat_mode", "paper mode")), |
| "avatar_path": str((d / avatar_rel).resolve()), |
| "lecture_prompt_template": _read_text_if_exists( |
| lecture_prompt_path, |
| DEFAULT_LECTURE_PROMPT_TEMPLATE, |
| ), |
| "mcq_prompt_template": _read_text_if_exists( |
| mcq_prompt_path, |
| DEFAULT_MCQ_PROMPT_TEMPLATE, |
| ), |
| "mcq_retry_prompt_template": _read_text_if_exists( |
| mcq_retry_prompt_path, |
| DEFAULT_MCQ_RETRY_PROMPT_TEMPLATE, |
| ), |
| "feedback_prompt_template": _read_text_if_exists( |
| feedback_prompt_path, |
| "", |
| ), |
| "lecture_prompt_path": str(lecture_prompt_path.resolve()), |
| "mcq_prompt_path": str(mcq_prompt_path.resolve()), |
| "mcq_retry_prompt_path": str(mcq_retry_prompt_path.resolve()), |
| "feedback_prompt_path": str(feedback_prompt_path.resolve()), |
| } |
| configs[cid] = config |
|
|
| if not configs: |
| |
| configs["default"] = { |
| "id": "default", |
| "display_name": "PDF Paper Tutor", |
| "tagline": "Research paper explainer · MCQ coach", |
| "byline": "By @local-demo", |
| "chat_label": "PDF Paper Tutor", |
| "chat_mode": "paper mode", |
| "avatar_path": str((APP_DIR / "avatar.jpg").resolve()) if (APP_DIR / "avatar.jpg").exists() else "", |
| "lecture_prompt_template": DEFAULT_LECTURE_PROMPT_TEMPLATE, |
| "mcq_prompt_template": DEFAULT_MCQ_PROMPT_TEMPLATE, |
| "mcq_retry_prompt_template": DEFAULT_MCQ_RETRY_PROMPT_TEMPLATE, |
| "feedback_prompt_template": "", |
| } |
| return configs |
|
|
|
|
| CHARACTER_CONFIGS = load_character_configs() |
| DEFAULT_CHARACTER_ID = next(iter(CHARACTER_CONFIGS.keys())) |
|
|
|
|
| def get_character_config(character_id: Optional[str]) -> Dict[str, Any]: |
| global CHARACTER_CONFIGS, DEFAULT_CHARACTER_ID |
| |
| CHARACTER_CONFIGS = load_character_configs() |
| if DEFAULT_CHARACTER_ID not in CHARACTER_CONFIGS: |
| DEFAULT_CHARACTER_ID = next(iter(CHARACTER_CONFIGS.keys())) |
| if character_id and character_id in CHARACTER_CONFIGS: |
| return CHARACTER_CONFIGS[character_id] |
| return CHARACTER_CONFIGS[DEFAULT_CHARACTER_ID] |
|
|
|
|
| @dataclass |
| class MCQItem: |
| question: str |
| options: List[str] |
| answer: str |
| explanation: str |
|
|
| def to_display_choices(self) -> List[str]: |
| labels = ["A", "B", "C", "D"] |
| return [f"{labels[i]}. {opt}" for i, opt in enumerate(self.options)] |
|
|
| def correct_choice_display(self) -> str: |
| idx = ["A", "B", "C", "D"].index(self.answer) |
| return self.to_display_choices()[idx] |
|
|
|
|
| def new_session_state() -> Dict[str, Any]: |
| return { |
| "lecture_text": "", |
| "lecture_audio_path": None, |
| "selected_paragraph_idx": "", |
| "explanation_audio_path": None, |
| "last_explanation_tts_text": "", |
| "pdf_path": None, |
| "pdf_excerpt": "", |
| "character_id": DEFAULT_CHARACTER_ID, |
| "exam_character_id": None, |
| "mcq_generating": False, |
| "current_page": "explain", |
| "mcqs": [], |
| "current_index": 0, |
| "score": 0, |
| "awaiting_next_after_wrong": False, |
| "completed": False, |
| "exam_chat": [], |
| "status": "Idle", |
| } |
|
|
|
|
| def strip_code_fence(text: str) -> str: |
| s = text.strip() |
| if s.startswith("```"): |
| s = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", s) |
| s = re.sub(r"\n?```$", "", s) |
| return s.strip() |
|
|
|
|
| def extract_pdf_text(pdf_path: str, max_chars: int = 16000) -> str: |
| if PdfReader is None: |
| return ( |
| "PDF text extraction library (pypdf) is unavailable. " |
| "Please install pypdf or switch to a Vision-based PDF reader implementation." |
| ) |
|
|
| reader = PdfReader(pdf_path) |
| chunks: List[str] = [] |
| total = 0 |
| for page_idx, page in enumerate(reader.pages, start=1): |
| try: |
| text = page.extract_text() or "" |
| except Exception: |
| text = "" |
| if text.strip(): |
| chunk = f"[Page {page_idx}]\n{text.strip()}\n" |
| chunks.append(chunk) |
| total += len(chunk) |
| if total >= max_chars: |
| break |
|
|
| if not chunks: |
| return ( |
| "No extractable text was found in the PDF. " |
| "For scanned PDFs, implement page-image rendering and pass images to Qwen-VL." |
| ) |
| return "\n".join(chunks)[:max_chars] |
|
|
|
|
| def write_tone_wav(text: str, out_path: str, seconds: float = 2.0, sample_rate: int = 16000) -> str: |
| |
| freq = 440 + (len(text) % 220) |
| amplitude = 9000 |
| frames = int(sample_rate * max(1.0, min(seconds, 8.0))) |
| with wave.open(out_path, "wb") as wf: |
| wf.setnchannels(1) |
| wf.setsampwidth(2) |
| wf.setframerate(sample_rate) |
| for i in range(frames): |
| sample = int(amplitude * math.sin(2 * math.pi * freq * (i / sample_rate))) |
| wf.writeframesraw(sample.to_bytes(2, byteorder="little", signed=True)) |
| return out_path |
|
|
|
|
| def normalize_option_text(text: Any) -> str: |
| s = str(text or "").strip() |
| s = re.sub(r"^\s*(?:[A-Da-d]\s*[\.\)\:\-]\s*)+", "", s).strip() |
| return s |
|
|
|
|
| def normalize_explanation_text(text: Any) -> str: |
| s = str(text or "").strip() |
| s = re.sub(r"^\s*(?:Explanation|Reason)\s*:\s*", "", s, flags=re.IGNORECASE).strip() |
| return s |
|
|
|
|
| def render_pdf_pages_for_vl(pdf_path: str, max_pages: int, scale: float) -> List[str]: |
| if pdfium is None: |
| raise RuntimeError("pypdfium2 is required to render PDF pages for Qwen3-VL.") |
| doc = pdfium.PdfDocument(pdf_path) |
| page_count = len(doc) |
| if page_count == 0: |
| raise RuntimeError("Uploaded PDF has no pages.") |
|
|
| render_dir = TMP_DIR / f"pdf_pages_{uuid.uuid4().hex}" |
| render_dir.mkdir(exist_ok=True) |
|
|
| paths: List[str] = [] |
| try: |
| for i in range(min(page_count, max_pages)): |
| page = doc[i] |
| pil = page.render(scale=scale).to_pil() |
| pil = pil.convert("RGB") |
| out_path = render_dir / f"page_{i+1:02d}.png" |
| pil.save(out_path, format="PNG") |
| paths.append(str(out_path)) |
| close_fn = getattr(page, "close", None) |
| if callable(close_fn): |
| close_fn() |
| finally: |
| close_fn = getattr(doc, "close", None) |
| if callable(close_fn): |
| close_fn() |
|
|
| if not paths: |
| raise RuntimeError("Failed to render PDF pages for Qwen3-VL.") |
| return paths |
|
|
|
|
| def image_file_to_data_url(image_path: str) -> str: |
| image_bytes = Path(image_path).read_bytes() |
| b64 = base64.b64encode(image_bytes).decode("ascii") |
| return f"data:image/png;base64,{b64}" |
|
|
|
|
| def _api_headers() -> Dict[str, str]: |
| if not API_KEY: |
| raise RuntimeError("Missing API_KEY. Put it in .env or environment variables.") |
| return { |
| "Authorization": f"Bearer {API_KEY}", |
| "Content-Type": "application/json", |
| } |
|
|
|
|
| def _require_api_url() -> str: |
| if not API_URL: |
| raise RuntimeError("Missing API_URL/API_UR. Put it in .env or environment variables.") |
| return API_URL.rstrip("/") |
|
|
|
|
| def _dashscope_tts_url() -> str: |
| base = _require_api_url() |
| if "/compatible-mode/" in base: |
| root = base.split("/compatible-mode/", 1)[0] |
| elif base.endswith("/v1"): |
| root = base[:-3] |
| else: |
| root = base |
| return f"{root}/api/v1/services/aigc/multimodal-generation/generation" |
|
|
|
|
| def _save_binary_audio(audio_bytes: bytes, out_path: str) -> str: |
| Path(out_path).write_bytes(audio_bytes) |
| return out_path |
|
|
|
|
| def _is_hf_tts_enabled() -> bool: |
| return bool(HF_TTS_SPACE_ID or HF_TTS_SPACE_URL) |
|
|
|
|
| def _tts_backend_name() -> str: |
| if USE_MOCK_TTS: |
| return "mock_tts" |
| if _is_hf_tts_enabled(): |
| return f"hf_space:{HF_TTS_SPACE_ID or HF_TTS_SPACE_URL}" |
| return "api_tts" |
|
|
|
|
| def _extract_audio_source(result: Any) -> str: |
| if isinstance(result, str): |
| return result |
| if isinstance(result, dict): |
| for key in ("path", "name", "url"): |
| value = result.get(key) |
| if isinstance(value, str) and value.strip(): |
| return value |
| nested = result.get("audio") |
| if nested is not None: |
| return _extract_audio_source(nested) |
| if isinstance(result, (list, tuple)): |
| for item in result: |
| try: |
| return _extract_audio_source(item) |
| except RuntimeError: |
| continue |
| raise RuntimeError(f"Unsupported HF Space audio output: {result!r}") |
|
|
|
|
| def _read_audio_bytes_from_source(source: str) -> bytes: |
| source = (source or "").strip() |
| if not source: |
| raise RuntimeError("HF Space returned an empty audio source.") |
| if source.startswith("http://") or source.startswith("https://"): |
| resp = requests.get(source, timeout=API_TIMEOUT_SEC) |
| if resp.status_code >= 400: |
| raise RuntimeError(f"Failed to fetch HF Space audio URL {resp.status_code}: {resp.text[:500]}") |
| return resp.content |
|
|
| path = Path(source) |
| if path.exists(): |
| return path.read_bytes() |
| raise RuntimeError(f"HF Space audio path does not exist: {source}") |
|
|
|
|
| def split_text_for_tts(text: str, max_len: int = 480) -> List[str]: |
| cleaned = re.sub(r"\s+", " ", (text or "")).strip() |
| if not cleaned: |
| return [] |
| if len(cleaned) <= max_len: |
| return [cleaned] |
|
|
| |
| pieces = re.split(r"(?<=[。!?!?;;::\.])\s*", cleaned) |
| chunks: List[str] = [] |
| buf = "" |
| for piece in pieces: |
| piece = piece.strip() |
| if not piece: |
| continue |
| if len(piece) > max_len: |
| if buf: |
| chunks.append(buf) |
| buf = "" |
| for i in range(0, len(piece), max_len): |
| chunks.append(piece[i:i + max_len]) |
| continue |
| candidate = f"{buf} {piece}".strip() if buf else piece |
| if len(candidate) <= max_len: |
| buf = candidate |
| else: |
| chunks.append(buf) |
| buf = piece |
| if buf: |
| chunks.append(buf) |
| return chunks |
|
|
|
|
| def split_text_every_two_sentences(text: str, max_len: int = 480) -> List[str]: |
| cleaned = re.sub(r"\s+", " ", (text or "")).strip() |
| if not cleaned: |
| return [] |
| if len(cleaned) <= max_len: |
| return [cleaned] |
|
|
| sentences = [s.strip() for s in re.split(r"(?<=[。!?!?;;::\.])\s*", cleaned) if s and s.strip()] |
| if not sentences: |
| return split_text_for_tts(cleaned, max_len=max_len) |
|
|
| groups: List[str] = [] |
| i = 0 |
| while i < len(sentences): |
| pair = " ".join(sentences[i:i + 2]).strip() |
| if pair: |
| groups.append(pair) |
| i += 2 |
|
|
| chunks: List[str] = [] |
| for g in groups: |
| if len(g) <= max_len: |
| chunks.append(g) |
| else: |
| chunks.extend(split_text_for_tts(g, max_len=max_len)) |
| return [c for c in chunks if c and c.strip()] |
|
|
|
|
| def concat_wav_files(wav_paths: List[str], out_path: str) -> str: |
| if not wav_paths: |
| raise RuntimeError("No WAV chunks to concatenate.") |
| if len(wav_paths) == 1: |
| return _save_binary_audio(Path(wav_paths[0]).read_bytes(), out_path) |
|
|
| params = None |
| frames: List[bytes] = [] |
| for p in wav_paths: |
| with wave.open(str(p), "rb") as wf: |
| cur_params = (wf.getnchannels(), wf.getsampwidth(), wf.getframerate()) |
| if params is None: |
| params = cur_params |
| elif cur_params != params: |
| raise RuntimeError("TTS WAV chunks have mismatched formats and cannot be concatenated.") |
| frames.append(wf.readframes(wf.getnframes())) |
|
|
| assert params is not None |
| with wave.open(out_path, "wb") as out: |
| out.setnchannels(params[0]) |
| out.setsampwidth(params[1]) |
| out.setframerate(params[2]) |
| for f in frames: |
| out.writeframes(f) |
| return out_path |
|
|
|
|
| class QwenPipelineEngine: |
| """ |
| Gradio-facing backend for: |
| PDF -> lecture text -> MCQs -> TTS audio |
| |
| This ships with a mock mode by default so the workflow is runnable immediately. |
| When USE_MOCK_MODELS=0, it calls remote APIs for text generation. |
| TTS mock is controlled separately by USE_MOCK_TTS. |
| - VL: OpenAI-compatible /chat/completions (works with DashScope compatible-mode and vLLM-style APIs) |
| - TTS: HF Space /tts_chunk (optional) or DashScope/OpenAI-compatible endpoints |
| """ |
|
|
| def __init__(self) -> None: |
| self.mock_mode = USE_MOCK_MODELS |
| self.vl_loaded = False |
| self.tts_loaded = False |
| self._pdf_page_cache: Dict[str, List[str]] = {} |
| self._hf_tts_client: Any = None |
|
|
| def ensure_vl_loaded(self) -> None: |
| if self.vl_loaded: |
| return |
| if self.mock_mode: |
| self.vl_loaded = True |
| return |
| _require_api_url() |
| if not API_KEY: |
| raise RuntimeError("Missing API_KEY for VL API calls.") |
| self.vl_loaded = True |
|
|
| def ensure_tts_loaded(self) -> None: |
| if self.tts_loaded: |
| return |
| if USE_MOCK_TTS: |
| self.tts_loaded = True |
| return |
| if _is_hf_tts_enabled(): |
| self._ensure_hf_tts_client() |
| self.tts_loaded = True |
| return |
| _require_api_url() |
| if not API_KEY: |
| raise RuntimeError("Missing API_KEY for TTS API calls.") |
| self.tts_loaded = True |
|
|
| def _ensure_hf_tts_client(self) -> Any: |
| if HFSpaceClient is None: |
| raise RuntimeError("Missing gradio_client. Please install with: pip install gradio_client") |
| if self._hf_tts_client is not None: |
| return self._hf_tts_client |
| src = HF_TTS_SPACE_URL or HF_TTS_SPACE_ID |
| if not src: |
| raise RuntimeError("Missing HF_TTS_SPACE_ID or HF_TTS_SPACE_URL.") |
| token = (HF_TOKEN or "").strip() |
| |
| if not token: |
| self._hf_tts_client = HFSpaceClient(src) |
| return self._hf_tts_client |
| try: |
| self._hf_tts_client = HFSpaceClient(src, hf_token=token) |
| except TypeError: |
| try: |
| self._hf_tts_client = HFSpaceClient(src, token=token) |
| except TypeError: |
| self._hf_tts_client = HFSpaceClient(src, headers={"Authorization": f"Bearer {token}"}) |
| return self._hf_tts_client |
|
|
| def _hf_space_tts_single(self, text: str, out_path: str, *, voice: str, language: str) -> str: |
| configured = (HF_TTS_API_NAME or "").strip() |
| normalized = configured.lstrip("/") |
|
|
| result: Any = None |
| last_exc: Optional[Exception] = None |
| api_candidates: List[str] = [] |
| for attempt in range(2): |
| client = self._ensure_hf_tts_client() |
| api_prefix = "" |
| cfg = getattr(client, "config", None) |
| if isinstance(cfg, dict): |
| api_prefix = str(cfg.get("api_prefix") or "").strip() |
|
|
| api_candidates = [] |
| prefixed = f"{api_prefix.rstrip('/')}/{normalized}" if api_prefix and normalized else "" |
| for cand in [ |
| configured, |
| f"/{normalized}" if normalized else "", |
| normalized, |
| prefixed, |
| "/gradio_api/tts_chunk", |
| "gradio_api/tts_chunk", |
| "/tts_chunk", |
| "tts_chunk", |
| "/predict", |
| "predict", |
| ]: |
| cand = cand.strip() |
| if cand and cand not in api_candidates: |
| api_candidates.append(cand) |
|
|
| result = None |
| last_exc = None |
| for api_name in api_candidates: |
| try: |
| result = client.predict( |
| text=text, |
| voice=voice, |
| language=language, |
| api_name=api_name, |
| ) |
| last_exc = None |
| break |
| except Exception as exc: |
| msg = str(exc) |
| lower_msg = msg.lower() |
| if ("cannot find a function" in lower_msg) and ("api_name" in lower_msg): |
| last_exc = exc |
| continue |
| raise |
| if last_exc is None: |
| break |
| |
| if attempt == 0: |
| self._hf_tts_client = None |
|
|
| if last_exc is not None: |
| available_hint = "" |
| view_api = getattr(client, "view_api", None) |
| if callable(view_api): |
| try: |
| api_info = view_api(return_format="dict") |
| available_hint = f" Available endpoints: {api_info}" |
| except Exception: |
| available_hint = "" |
| tried = ", ".join(api_candidates) |
| raise RuntimeError(f"No matching HF API endpoint. Tried: [{tried}].{available_hint}") from last_exc |
| source = _extract_audio_source(result) |
| audio_bytes = _read_audio_bytes_from_source(source) |
| return _save_binary_audio(audio_bytes, out_path) |
|
|
| def _mock_generate_lecture(self, pdf_excerpt: str) -> str: |
| excerpt = re.sub(r"\s+", " ", pdf_excerpt).strip() |
| excerpt = excerpt[:1000] |
| return ( |
| f" {excerpt}" |
| ) |
|
|
| def _mock_generate_mcqs(self, lecture_text: str) -> List[MCQItem]: |
| base_questions = [ |
| MCQItem( |
| question="What type of core problem does this paper most likely address?", |
| options=["Performance or efficiency bottlenecks in existing methods", "How to design database indexes", "How to build a frontend page", "How to compress video files"], |
| answer="A", |
| explanation="Paper-reading tasks usually focus on limitations of prior methods, then propose improvements in performance, efficiency, or robustness.", |
| ), |
| MCQItem( |
| question="What is the best way to explain a paper's method?", |
| options=["Explain the pipeline from input to output by modules or steps", "Only list references", "Only show experiment tables without method details", "Only present conclusions without background"], |
| answer="A", |
| explanation="A structured, step-by-step explanation helps learners understand how the paper moves from problem to solution.", |
| ), |
| MCQItem( |
| question="Why provide both answers and explanations in MCQs?", |
| options=["To enable feedback and error correction", "Only to make JSON longer", "Because Gradio requires explanations", "To reduce the number of questions"], |
| answer="A", |
| explanation="Answer + explanation completes the teaching loop and helps users learn from mistakes.", |
| ), |
| MCQItem( |
| question="What is the risk of feeding a very long paper in one shot?", |
| options=["Context overflow can increase cost and cause information loss or failure", "The model automatically becomes more accurate", "TTS audio becomes shorter", "The PDF file gets corrupted"], |
| answer="A", |
| explanation="Long documents usually need chunking and summarization to avoid context-window issues and quality degradation.", |
| ), |
| MCQItem( |
| question="In this demo pipeline, what is Qwen TTS used for?", |
| options=["Convert lecture text and explanations into audio", "Convert PDF to images", "Train Qwen3-VL-8B", "Generate new MCQ answers"], |
| answer="A", |
| explanation="TTS turns text explanations into speech, improving interactivity and accessibility.", |
| ), |
| ] |
| return base_questions |
|
|
| def _get_pdf_page_images(self, pdf_path: str) -> List[str]: |
| cache_key = str(Path(pdf_path).resolve()) |
| cached = self._pdf_page_cache.get(cache_key) |
| if cached and all(Path(p).exists() for p in cached): |
| return cached |
| page_paths = render_pdf_pages_for_vl( |
| pdf_path, |
| max_pages=QWEN_VL_MAX_PAGES, |
| scale=QWEN_VL_RENDER_SCALE, |
| ) |
| self._pdf_page_cache[cache_key] = page_paths |
| return page_paths |
|
|
| def _chat_completions( |
| self, |
| messages: List[Dict[str, Any]], |
| max_tokens: int, |
| *, |
| temperature: Optional[float] = None, |
| top_p: Optional[float] = None, |
| ) -> str: |
| url = f"{_require_api_url()}/chat/completions" |
| payload: Dict[str, Any] = { |
| "model": CHAT_MODEL_ID, |
| "messages": messages, |
| "max_tokens": max_tokens, |
| "stream": False, |
| } |
| if temperature is not None: |
| payload["temperature"] = float(temperature) |
| if top_p is not None: |
| payload["top_p"] = float(top_p) |
| resp = requests.post(url, headers=_api_headers(), json=payload, timeout=API_TIMEOUT_SEC) |
| if resp.status_code >= 400: |
| raise RuntimeError(f"VL API error {resp.status_code}: {resp.text[:1000]}") |
| data = resp.json() |
| choices = data.get("choices") or [] |
| if not choices: |
| raise RuntimeError(f"VL API returned no choices: {data}") |
| content = choices[0].get("message", {}).get("content", "") |
| if isinstance(content, str): |
| return content.strip() |
| if isinstance(content, list): |
| parts: List[str] = [] |
| for item in content: |
| if isinstance(item, dict) and item.get("type") in {"text", "output_text"}: |
| parts.append(str(item.get("text") or item.get("content") or "")) |
| return "\n".join([p for p in parts if p]).strip() |
| return str(content).strip() |
|
|
| def _real_generate_text_from_pdf( |
| self, |
| pdf_path: str, |
| prompt: str, |
| max_tokens: Optional[int] = None, |
| *, |
| temperature: Optional[float] = None, |
| top_p: Optional[float] = None, |
| ) -> str: |
| page_image_paths = self._get_pdf_page_images(pdf_path) |
| content: List[Dict[str, Any]] = [] |
| for p in page_image_paths: |
| content.append({"type": "image_url", "image_url": {"url": image_file_to_data_url(p)}}) |
| content.append({"type": "text", "text": prompt}) |
| messages = [{"role": "user", "content": content}] |
| return self._chat_completions( |
| messages, |
| max_tokens=max_tokens or QWEN_VL_MAX_NEW_TOKENS, |
| temperature=temperature, |
| top_p=top_p, |
| ) |
|
|
| def _real_tts_single(self, text: str, out_path: str, *, voice: Optional[str] = None) -> str: |
| if not text.strip(): |
| return write_tone_wav("empty", out_path) |
|
|
| if _is_hf_tts_enabled(): |
| try: |
| return self._hf_space_tts_single( |
| text, |
| out_path, |
| voice=str(voice or HF_TTS_VOICE), |
| language=HF_TTS_LANGUAGE, |
| ) |
| except Exception as exc: |
| if not HF_TTS_ALLOW_FALLBACK: |
| raise RuntimeError(f"HF Space TTS failed and fallback is disabled: {type(exc).__name__}: {exc}") |
| if USE_MOCK_TTS: |
| return write_tone_wav(text, out_path) |
|
|
| openai_url = f"{_require_api_url()}/audio/speech" |
| openai_payload = { |
| "model": TTS_MODEL_ID, |
| "input": text, |
| "voice": TTS_SPEAKER, |
| "format": TTS_FORMAT, |
| } |
| openai_resp = requests.post( |
| openai_url, |
| headers=_api_headers(), |
| json=openai_payload, |
| timeout=API_TIMEOUT_SEC, |
| ) |
| content_type = openai_resp.headers.get("content-type", "") |
| if openai_resp.status_code < 400 and "application/json" not in content_type.lower(): |
| return _save_binary_audio(openai_resp.content, out_path) |
|
|
| |
| payload = { |
| "model": TTS_MODEL_ID, |
| "input": {"text": text}, |
| "parameters": {"voice": TTS_SPEAKER, "format": TTS_FORMAT}, |
| } |
| resp = requests.post( |
| _dashscope_tts_url(), |
| headers=_api_headers(), |
| json=payload, |
| timeout=API_TIMEOUT_SEC, |
| ) |
| if resp.status_code >= 400: |
| err1 = openai_resp.text[:500] if openai_resp.text else "" |
| err2 = resp.text[:500] if resp.text else "" |
| raise RuntimeError( |
| f"TTS API failed. openai-compatible: {openai_resp.status_code} {err1}; " |
| f"dashscope: {resp.status_code} {err2}" |
| ) |
| data = resp.json() |
| audio_url = ( |
| (((data.get("output") or {}).get("audio") or {}).get("url")) |
| or (((data.get("output") or {}).get("audio_url"))) |
| ) |
| if not audio_url: |
| raise RuntimeError(f"TTS API returned no audio URL: {data}") |
| audio_resp = requests.get(audio_url, timeout=API_TIMEOUT_SEC) |
| if audio_resp.status_code >= 400: |
| raise RuntimeError(f"Failed to download TTS audio {audio_resp.status_code}: {audio_resp.text[:500]}") |
| return _save_binary_audio(audio_resp.content, out_path) |
|
|
| def _synthesize_tts_chunks(self, chunks: List[str], out_path: str, *, voice: Optional[str] = None) -> str: |
| chunks = [str(c or "").strip() for c in chunks if str(c or "").strip()] |
| if not chunks: |
| return write_tone_wav("empty", out_path) |
| if len(chunks) == 1: |
| return self._real_tts_single(chunks[0], out_path, voice=voice) |
|
|
| chunk_paths: List[str] = [] |
| for idx, chunk in enumerate(chunks, start=1): |
| chunk_path = str(TMP_DIR / f"tts_chunk_{idx}_{uuid.uuid4().hex}.wav") |
| chunk_paths.append(self._real_tts_single(chunk, chunk_path, voice=voice)) |
| return concat_wav_files(chunk_paths, out_path) |
|
|
| def _real_tts(self, text: str, out_path: str, *, voice: Optional[str] = None) -> str: |
| cleaned = str(text or "").strip() |
| if not cleaned: |
| return write_tone_wav("empty", out_path) |
|
|
| if TEXT_SPLIT_TO_CHUNK: |
| return self._synthesize_tts_chunks(split_text_for_tts(cleaned, max_len=480), out_path, voice=voice) |
|
|
| try: |
| return self._real_tts_single(cleaned, out_path, voice=voice) |
| except Exception as exc: |
| err = str(exc).lower() |
| too_long = ( |
| "text too long" in err |
| or "too long for chunk-level api" in err |
| or "chunk-level api" in err |
| ) |
| if not too_long: |
| raise |
| return self._synthesize_tts_chunks( |
| split_text_every_two_sentences(cleaned, max_len=480), |
| out_path, |
| voice=voice, |
| ) |
|
|
| @spaces.GPU |
| def build_lesson_and_quiz(self, pdf_path: str, character_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: |
| self.ensure_vl_loaded() |
| pdf_excerpt = extract_pdf_text(pdf_path) |
| cfg = character_cfg or get_character_config(None) |
| lecture_template = cfg.get("lecture_prompt_template", DEFAULT_LECTURE_PROMPT_TEMPLATE) |
| mcq_template = cfg.get("mcq_prompt_template", DEFAULT_MCQ_PROMPT_TEMPLATE) |
| mcq_retry_template = cfg.get("mcq_retry_prompt_template", DEFAULT_MCQ_RETRY_PROMPT_TEMPLATE) |
|
|
| if self.mock_mode: |
| lecture_text = self._mock_generate_lecture(pdf_excerpt) |
| mcqs = self._mock_generate_mcqs(lecture_text) |
| else: |
| lecture_prompt = render_prompt_template( |
| str(lecture_template), |
| pdf_excerpt, |
| replacements={"style_seed": uuid.uuid4().hex}, |
| ) |
| lecture_text = self._real_generate_text_from_pdf( |
| pdf_path, |
| lecture_prompt, |
| max_tokens=QWEN_VL_MAX_NEW_TOKENS, |
| temperature=0.9, |
| top_p=0.95, |
| ) |
| quiz_prompt = render_prompt_template(str(mcq_template), pdf_excerpt) |
| raw_mcq_json = self._real_generate_text_from_pdf( |
| pdf_path, |
| quiz_prompt, |
| max_tokens=QWEN_VL_MCQ_MAX_NEW_TOKENS, |
| temperature=0.2, |
| top_p=0.9, |
| ) |
| try: |
| mcqs = parse_mcq_json(raw_mcq_json) |
| except json.JSONDecodeError: |
| retry_prompt = render_prompt_template(str(mcq_retry_template), pdf_excerpt) |
| retry_raw = self._real_generate_text_from_pdf( |
| pdf_path, |
| retry_prompt, |
| max_tokens=QWEN_VL_MCQ_MAX_NEW_TOKENS, |
| temperature=0.2, |
| top_p=0.9, |
| ) |
| mcqs = parse_mcq_json(retry_raw) |
|
|
| return { |
| "lecture_text": lecture_text, |
| "mcqs": [asdict(q) for q in mcqs], |
| "pdf_excerpt": pdf_excerpt, |
| } |
|
|
| @spaces.GPU |
| def build_lecture(self, pdf_path: str, character_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: |
| self.ensure_vl_loaded() |
| pdf_excerpt = extract_pdf_text(pdf_path) |
| cfg = character_cfg or get_character_config(None) |
| lecture_template = cfg.get("lecture_prompt_template", DEFAULT_LECTURE_PROMPT_TEMPLATE) |
|
|
| if self.mock_mode: |
| lecture_text = self._mock_generate_lecture(pdf_excerpt) |
| else: |
| lecture_prompt = render_prompt_template( |
| str(lecture_template), |
| pdf_excerpt, |
| replacements={"style_seed": uuid.uuid4().hex}, |
| ) |
| lecture_text = self._real_generate_text_from_pdf( |
| pdf_path, |
| lecture_prompt, |
| max_tokens=QWEN_VL_MAX_NEW_TOKENS, |
| temperature=0.9, |
| top_p=0.95, |
| ) |
|
|
| return { |
| "lecture_text": lecture_text, |
| "pdf_excerpt": pdf_excerpt, |
| } |
|
|
| @spaces.GPU |
| def build_mcqs(self, pdf_path: str, pdf_excerpt: str, character_cfg: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: |
| self.ensure_vl_loaded() |
| cfg = character_cfg or get_character_config(None) |
| mcq_template = cfg.get("mcq_prompt_template", DEFAULT_MCQ_PROMPT_TEMPLATE) |
| mcq_retry_template = cfg.get("mcq_retry_prompt_template", DEFAULT_MCQ_RETRY_PROMPT_TEMPLATE) |
|
|
| if self.mock_mode: |
| mcqs = self._mock_generate_mcqs(pdf_excerpt) |
| return rebalance_mcq_answers([asdict(q) for q in mcqs]) |
|
|
| quiz_prompt = render_prompt_template(str(mcq_template), pdf_excerpt) |
| raw_mcq_json = self._real_generate_text_from_pdf( |
| pdf_path, |
| quiz_prompt, |
| max_tokens=QWEN_VL_MCQ_MAX_NEW_TOKENS, |
| temperature=0.2, |
| top_p=0.9, |
| ) |
| try: |
| mcqs = parse_mcq_json(raw_mcq_json) |
| except (json.JSONDecodeError, ValueError): |
| retry_prompt = render_prompt_template(str(mcq_retry_template), pdf_excerpt) |
| retry_raw = self._real_generate_text_from_pdf( |
| pdf_path, |
| retry_prompt, |
| max_tokens=QWEN_VL_MCQ_MAX_NEW_TOKENS, |
| temperature=0.2, |
| top_p=0.9, |
| ) |
| mcqs = parse_mcq_json(retry_raw) |
| return rebalance_mcq_answers([asdict(q) for q in mcqs]) |
|
|
| @spaces.GPU |
| def synthesize_tts(self, text: str, name_prefix: str = "audio", *, voice: Optional[str] = None) -> str: |
| self.ensure_tts_loaded() |
| out_path = str(TMP_DIR / f"{name_prefix}_{uuid.uuid4().hex}.wav") |
| if USE_MOCK_TTS: |
| return write_tone_wav(text, out_path) |
| return self._real_tts(text, out_path, voice=voice) |
|
|
|
|
| def parse_mcq_json(raw: str) -> List[MCQItem]: |
| def _normalize_answer_label(answer_raw: Any, options: List[str]) -> str: |
| s = str(answer_raw or "").strip() |
| if not s: |
| return "" |
| up = s.upper() |
| if up in {"A", "B", "C", "D"}: |
| return up |
| m = re.search(r"\b([ABCD])\b", up) |
| if m: |
| return m.group(1) |
| if up.startswith("OPTION "): |
| tail = up.replace("OPTION ", "", 1).strip() |
| if tail in {"A", "B", "C", "D"}: |
| return tail |
| normalized_answer_text = normalize_option_text(s).strip().lower() |
| if normalized_answer_text: |
| for i, opt in enumerate(options[:4]): |
| if normalized_answer_text == normalize_option_text(opt).strip().lower(): |
| return ["A", "B", "C", "D"][i] |
| return "" |
|
|
| cleaned = strip_code_fence(raw) |
| try: |
| payload = json.loads(cleaned) |
| except json.JSONDecodeError: |
| start = cleaned.find("{") |
| end = cleaned.rfind("}") |
| if start != -1 and end != -1 and end > start: |
| payload = json.loads(cleaned[start:end + 1]) |
| else: |
| raise |
| if isinstance(payload, list): |
| questions = payload |
| else: |
| questions = payload.get("questions", []) or payload.get("items", []) or payload.get("data", []) |
| parsed: List[MCQItem] = [] |
| for item in questions[:5]: |
| if not isinstance(item, dict): |
| continue |
| q = str(item.get("question", "")).strip() |
| options_raw = item.get("options", []) |
| if not isinstance(options_raw, list): |
| options_raw = item.get("choices", []) if isinstance(item.get("choices", []), list) else [] |
| options = [normalize_option_text(x) for x in options_raw][:4] |
| explanation = str( |
| item.get("explanation", "") |
| or item.get("rationale", "") |
| or item.get("reason", "") |
| ).strip() |
| answer = _normalize_answer_label( |
| item.get("answer", "") |
| or item.get("correct_answer", "") |
| or item.get("correctOption", "") |
| or item.get("correct", ""), |
| options, |
| ) |
| if not answer: |
| idx_value = item.get("answer_index", item.get("correct_index", None)) |
| try: |
| idx = int(idx_value) |
| if 0 <= idx < 4: |
| answer = ["A", "B", "C", "D"][idx] |
| except Exception: |
| pass |
| if len(options) != 4: |
| continue |
| if answer not in {"A", "B", "C", "D"}: |
| continue |
| if not q or not explanation: |
| continue |
| parsed.append(MCQItem(question=q, options=options, answer=answer, explanation=explanation)) |
| if len(parsed) != 5: |
| raise ValueError(f"Expected 5 MCQs, got {len(parsed)}") |
| return parsed |
|
|
|
|
| def rebalance_mcq_answers(mcqs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
| labels = ["A", "B", "C", "D"] |
| n = min(5, len(mcqs)) |
| rng = random.Random(uuid.uuid4().int) |
| targets = labels[:] |
| rng.shuffle(targets) |
| while len(targets) < n: |
| targets.append(rng.choice(labels)) |
| out: List[Dict[str, Any]] = [] |
| for i, q in enumerate(mcqs[:n]): |
| opts = list(q.get("options", []) or []) |
| ans = str(q.get("answer", "")).strip().upper() |
| if len(opts) != 4 or ans not in {"A", "B", "C", "D"}: |
| out.append(q) |
| continue |
| correct_idx = labels.index(ans) |
| correct_opt = opts[correct_idx] |
| distractors = [opts[j] for j in range(4) if j != correct_idx] |
| target_idx = labels.index(targets[i]) |
| new_opts: List[str] = [] |
| d_i = 0 |
| for j in range(4): |
| if j == target_idx: |
| new_opts.append(correct_opt) |
| else: |
| new_opts.append(distractors[d_i]) |
| d_i += 1 |
| q2 = dict(q) |
| q2["options"] = new_opts |
| q2["answer"] = labels[target_idx] |
| out.append(q2) |
| return out |
|
|
|
|
| engine = QwenPipelineEngine() |
|
|
|
|
| def get_current_mcq(state: Dict[str, Any]) -> Optional[Dict[str, Any]]: |
| idx = state.get("current_index", 0) |
| mcqs = state.get("mcqs", []) |
| if not mcqs or idx < 0 or idx >= len(mcqs): |
| return None |
| return mcqs[idx] |
|
|
|
|
| def format_question_block(state: Dict[str, Any]) -> str: |
| mcq = get_current_mcq(state) |
| if mcq is None: |
| if state.get("completed"): |
| total = len(state.get("mcqs", [])) |
| return f"### Quiz Completed\nScore: {state.get('score', 0)} / {total}" |
| return "### No question loaded" |
| qn = state["current_index"] + 1 |
| total = len(state["mcqs"]) |
| return f"### Question {qn}/{total}\n\n{mcq['question']}" |
|
|
|
|
| def current_choices(state: Dict[str, Any]) -> List[str]: |
| mcq = get_current_mcq(state) |
| if mcq is None: |
| return [] |
| labels = ["A", "B", "C", "D"] |
| return [f"{labels[i]}. {normalize_option_text(opt)}" for i, opt in enumerate(mcq["options"])] |
|
|
|
|
| def score_text(state: Dict[str, Any]) -> str: |
| total = len(state.get("mcqs", [])) |
| return f"Score: {state.get('score', 0)} / {total}" |
|
|
|
|
| def _exam_chat_text_for_question(state: Dict[str, Any], mcq: Dict[str, Any]) -> str: |
| qn = state.get("current_index", 0) + 1 |
| total = len(state.get("mcqs", [])) |
| labels = ["A", "B", "C", "D"] |
| options = mcq.get("options", []) |
| lines = [f"Question {qn}/{total}", str(mcq.get("question", "")).strip(), ""] |
| for i in range(min(4, len(options))): |
| lines.append(f"{labels[i]}. {normalize_option_text(options[i])}") |
| return "\n".join([x for x in lines if x is not None]).strip() |
|
|
|
|
| def _ensure_current_question_in_exam_chat(state: Dict[str, Any]) -> None: |
| if not state.get("mcqs") or state.get("completed"): |
| return |
| chat: List[Dict[str, Any]] = state.setdefault("exam_chat", []) |
| q_index = int(state.get("current_index", 0)) |
| for msg in reversed(chat): |
| if msg.get("kind") == "mcq": |
| if int(msg.get("q_index", -1)) == q_index: |
| return |
| break |
| mcq = get_current_mcq(state) |
| if mcq is None: |
| return |
| chat.append({"role": "assistant", "kind": "mcq", "q_index": q_index, "text": _exam_chat_text_for_question(state, mcq)}) |
|
|
|
|
| def _append_exam_user_answer(state: Dict[str, Any], choice: str) -> None: |
| chat: List[Dict[str, Any]] = state.setdefault("exam_chat", []) |
| q_index = int(state.get("current_index", 0)) |
| display = choice |
| if "." in choice: |
| _, rest = choice.split(".", 1) |
| if rest.strip(): |
| display = rest.strip() |
| chat.append({"role": "user", "kind": "answer", "q_index": q_index, "text": display}) |
|
|
|
|
| def _append_exam_assistant_text(state: Dict[str, Any], text: str, *, kind: str = "note") -> None: |
| chat: List[Dict[str, Any]] = state.setdefault("exam_chat", []) |
| q_index = int(state.get("current_index", 0)) |
| chat.append({"role": "assistant", "kind": kind, "q_index": q_index, "text": text}) |
|
|
|
|
| def _score_band(score: int, total: int) -> str: |
| if total <= 0: |
| return "none" |
| ratio = score / total |
| if ratio >= 0.9: |
| return "excellent" |
| if ratio >= 0.7: |
| return "good" |
| if ratio >= 0.5: |
| return "fair" |
| return "poor" |
|
|
|
|
| def _pick_variant(items: List[str], seed: int) -> str: |
| if not items: |
| return "" |
| return items[seed % len(items)] |
|
|
|
|
| def _character_feedback_style_from_mcq_prompt(character_id: str) -> str: |
| cfg = get_character_config(character_id) |
| prompt_text = str(cfg.get("mcq_prompt_template", "") or "") |
| if not prompt_text.strip(): |
| return "" |
|
|
| role_line = "" |
| tone_line = "" |
| in_tone_block = False |
| for raw in prompt_text.splitlines(): |
| line = raw.strip() |
| if not line: |
| continue |
| lower = line.lower() |
| if not role_line and lower.startswith("you are "): |
| role_line = line |
| continue |
| if lower.startswith("tone:"): |
| in_tone_block = True |
| continue |
| if in_tone_block: |
| |
| if line.endswith(":"): |
| in_tone_block = False |
| continue |
| tone_line = line |
| in_tone_block = False |
|
|
| style_parts: List[str] = [] |
| if role_line: |
| style_parts.append(role_line.rstrip(".")) |
| if tone_line: |
| style_parts.append(f"Tone: {tone_line}") |
| return " ".join(style_parts).strip() |
|
|
|
|
| def _examiner_style_prompt(character_id: str) -> str: |
| cfg = get_character_config(character_id) |
| feedback_prompt = str(cfg.get("feedback_prompt_template", "") or "").strip() |
| if feedback_prompt: |
| return feedback_prompt |
|
|
| character_style = _character_feedback_style_from_mcq_prompt(character_id) |
| if character_style: |
| return ( |
| f"{character_style}. " |
| "You are giving live exam feedback after each answer. " |
| "Respond in concise English, in-character, practical, and pointed. " |
| "No markdown, no emojis, no stage directions." |
| ) |
| return ( |
| "You are an examiner giving live feedback after each answer. " |
| "Respond in concise English and focus on the student's performance. " |
| "No markdown, no emojis." |
| ) |
|
|
|
|
| def _llm_exam_feedback(messages: List[Dict[str, Any]], *, max_tokens: int = 120) -> str: |
| engine.ensure_vl_loaded() |
| return engine._chat_completions(messages, max_tokens=max_tokens, temperature=0.9, top_p=0.95) |
|
|
|
|
| def _llm_short_exam_remark(character_id: str, *, kind: str, context: str = "") -> str: |
| if engine.mock_mode: |
| return "" |
| ctx = " ".join(str(context or "").strip().split()) |
| if kind == "correct": |
| instruction = f"Write ONE short English sentence for a correct answer. Context: {ctx}. Max 16 words. No markdown. No emojis." |
| elif kind == "incorrect": |
| instruction = f"Write ONE short English sentence for an incorrect answer without giving the option letter. Context: {ctx}. Max 20 words. No markdown. No emojis." |
| else: |
| instruction = f"Write 1-2 short English final remarks with one concrete revision suggestion. Context: {ctx}. Max 28 words total. No markdown. No emojis." |
| text = _llm_exam_feedback( |
| [ |
| {"role": "system", "content": _examiner_style_prompt(character_id)}, |
| {"role": "user", "content": instruction}, |
| ], |
| max_tokens=80 if kind in {"correct", "incorrect"} else 120, |
| ) |
| return " ".join(str(text or "").strip().split()) |
|
|
|
|
| def exam_feedback_correct(character_id: str, *, q_index: int) -> str: |
| if engine.mock_mode: |
| cid = (character_id or "").lower() |
| if "snape" in cid: |
| return _pick_variant( |
| [ |
| "Correct. Keep going.", |
| "Right answer. Stay focused.", |
| "Good. Next question.", |
| "Exactly. Keep your pace.", |
| ], |
| q_index, |
| ) |
| if "mcgonagall" in cid or "mcg" in cid: |
| return _pick_variant( |
| [ |
| "That's correct. Keep it up.", |
| "Good work. Move on.", |
| "Well done. Stay consistent.", |
| "Precisely. Continue.", |
| ], |
| q_index, |
| ) |
| return "That's right." |
| try: |
| remark = _llm_short_exam_remark( |
| character_id, |
| kind="correct", |
| context=f"Question {q_index + 1} answered correctly.", |
| ) |
| if remark: |
| return remark |
| except Exception: |
| pass |
| return "That's right." |
|
|
|
|
| def exam_feedback_incorrect( |
| character_id: str, |
| *, |
| q_index: int, |
| correct_choice_display: str, |
| explanation: str, |
| ) -> str: |
| explanation = normalize_explanation_text(explanation) |
| if engine.mock_mode: |
| cid = (character_id or "").lower() |
| if "snape" in cid: |
| opener = _pick_variant( |
| [ |
| "Wrong. Read more carefully.", |
| "Incorrect. Check the prompt details.", |
| "Not correct. Your reading is too loose.", |
| "Incorrect. Be more rigorous.", |
| ], |
| q_index, |
| ) |
| return f"{opener}\nThe correct answer is {correct_choice_display}\n\n{explanation}" |
| if "mcgonagall" in cid or "mcg" in cid: |
| opener = _pick_variant( |
| [ |
| "Incorrect. Think first, then answer.", |
| "Not quite. Slow down and read precisely.", |
| "Wrong. Stop guessing.", |
| "Incorrect. Focus on the method itself.", |
| ], |
| q_index, |
| ) |
| return f"{opener}\nThe correct answer is {correct_choice_display}\n\n{explanation}" |
| return f"Incorrect.\nThe correct answer is {correct_choice_display}\n\n{explanation}" |
| try: |
| remark = _llm_short_exam_remark( |
| character_id, |
| kind="incorrect", |
| context=f"Question {q_index + 1} answered incorrectly.", |
| ) |
| if remark: |
| return f"{remark}\nThe correct answer is {correct_choice_display}\n\n{explanation}" |
| except Exception: |
| pass |
| return f"Incorrect.\nThe correct answer is {correct_choice_display}\n\n{explanation}" |
|
|
|
|
| def exam_feedback_final(character_id: str, *, score: int, total: int) -> str: |
| if engine.mock_mode: |
| cid = (character_id or "").lower() |
| band = _score_band(score, total) |
| if "snape" in cid: |
| mapping = { |
| "excellent": "Excellent performance this time.", |
| "good": "Good. Keep polishing details.", |
| "fair": "Fair. More practice is needed.", |
| "poor": "Poor. Review the lecture and retry.", |
| "none": "No score available yet.", |
| } |
| return mapping.get(band, "Quiz finished.") |
| if "mcgonagall" in cid or "mcg" in cid: |
| mapping = { |
| "excellent": "Excellent. Keep this standard.", |
| "good": "Good understanding. Improve the details.", |
| "fair": "Passable, but not stable yet.", |
| "poor": "Not acceptable. Review and try again.", |
| "none": "No score available yet.", |
| } |
| return mapping.get(band, "Quiz finished.") |
| return f"Final score: {score} / {total}." |
| try: |
| remark = _llm_short_exam_remark( |
| character_id, |
| kind="final", |
| context=f"Final score: {score} / {total}.", |
| ) |
| if remark: |
| return remark |
| except Exception: |
| pass |
| return f"Final score: {score} / {total}." |
|
|
|
|
| def _roleplay_explain_feedback(character_id: str) -> str: |
| cid = (character_id or "").lower() |
| if "snape" in cid: |
| return "Lecture is ready. Select a chunk to play, then go to the exam." |
| if "mcgonagall" in cid or "mcg" in cid: |
| return "Lecture is ready. Review it carefully, then enter the exam." |
| return "Lecture is ready. Review it, then enter the exam." |
|
|
|
|
| def _roleplay_loading_text(character_id: str, *, phase: str) -> str: |
| cfg = get_character_config(character_id) |
| name = str(cfg.get("display_name", "Professor")) |
| cid = (character_id or "").lower() |
| if phase == "lecture": |
| if "snape" in cid: |
| return f"Professor {name} is scrutinizing your paper…" |
| if "mcgonagall" in cid or "mcg" in cid: |
| return f"Professor {name} is reviewing your paper with strict precision…" |
| return f"Professor {name} is reviewing your paper…" |
| if "snape" in cid: |
| return f"Professor {name} is preparing a rigorous exam…" |
| if "mcgonagall" in cid or "mcg" in cid: |
| return f"Professor {name} is preparing challenging questions…" |
| return f"Professor {name} is preparing your exam materials…" |
|
|
|
|
| def build_loading_html(text: str) -> str: |
| safe = html.escape(str(text or ""), quote=False) |
| if not safe: |
| return "" |
| return f""" |
| <div class="gen-loading-inner"> |
| <div class="loader"></div> |
| <div class="gen-loading-text">{safe}</div> |
| </div> |
| """.strip() |
|
|
|
|
| def _build_exam_chat_avatar_html(character_id: Optional[str]) -> str: |
| cfg = get_character_config(character_id) |
| avatar_url = _image_data_url(Path(cfg.get("avatar_path", ""))) if cfg.get("avatar_path") else "" |
| return f'<img class="exam-chat-avatar" src="{avatar_url}" alt="avatar" />' if avatar_url else "" |
|
|
|
|
| def build_exam_chat_html(state: Dict[str, Any]) -> str: |
| chat: List[Dict[str, Any]] = state.get("exam_chat", []) or [] |
| if not chat and state.get("mcqs") and not state.get("completed"): |
| mcq = get_current_mcq(state) |
| if mcq is not None: |
| chat = [{"role": "assistant", "kind": "mcq", "q_index": int(state.get("current_index", 0)), "text": _exam_chat_text_for_question(state, mcq)}] |
|
|
| character_id = state.get("exam_character_id") or DEFAULT_CHARACTER_ID |
| avatar_html = _build_exam_chat_avatar_html(character_id) |
|
|
| parts: List[str] = ['<div class="exam-chat-wrap">'] |
| for msg in chat: |
| role = msg.get("role", "assistant") |
| safe = html.escape(str(msg.get("text", "")), quote=False).replace("\n", "<br>") |
| if role == "user": |
| parts.append(f'<div class="exam-msg user"><div class="bubble user">{safe}</div></div>') |
| else: |
| parts.append(f'<div class="exam-msg assistant">{avatar_html}<div class="bubble assistant">{safe}</div></div>') |
| parts.append("</div>") |
| return "".join(parts) |
|
|
|
|
| def reset_ui_from_state( |
| state: Dict[str, Any], |
| feedback: str = "", |
| *, |
| results_visible: bool = True, |
| loading_visible: bool = False, |
| loading_text: str = "", |
| exam_picker_visible: bool = False, |
| ): |
| quiz_ready = bool(state.get("mcqs")) |
| current_page = state.get("current_page", "explain") |
| explain_character_id = state.get("character_id") or DEFAULT_CHARACTER_ID |
| exam_character_id = state.get("exam_character_id") or explain_character_id |
| top_character_id = exam_character_id if current_page == "exam" else explain_character_id |
| top_picker_value = top_character_id |
| show_explain_page = results_visible and current_page != "exam" |
| show_exam_page = results_visible and current_page == "exam" |
| submit_interactive = quiz_ready and not state.get("completed", False) |
| radio_interactive = submit_interactive |
| lecture_tts_ready = bool(state.get("lecture_text")) |
| picker_choices = paragraph_picker_choices(state.get("lecture_text", "")) |
| selected_paragraph_value = paragraph_picker_value_for_idx( |
| state.get("lecture_text", ""), |
| str(state.get("selected_paragraph_idx", "")).strip(), |
| ) |
| if selected_paragraph_value is None and picker_choices: |
| selected_paragraph_value = picker_choices[0][1] |
| if state.get("completed"): |
| radio_interactive = False |
| return ( |
| state, |
| build_character_header_html(top_character_id), |
| gr.update(value=top_picker_value), |
| build_chat_avatar_html(top_character_id), |
| build_chat_meta_html(top_character_id), |
| gr.update(value=build_loading_html(loading_text), visible=loading_visible), |
| gr.update(visible=show_explain_page), |
| gr.update(visible=show_exam_page), |
| state.get("status", "Idle"), |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| gr.update( |
| choices=picker_choices, |
| value=selected_paragraph_value, |
| interactive=lecture_tts_ready, |
| visible=lecture_tts_ready, |
| ), |
| state.get("lecture_audio_path", None), |
| gr.update(interactive=lecture_tts_ready), |
| gr.update(visible=lecture_tts_ready, interactive=lecture_tts_ready), |
| gr.update(visible=lecture_tts_ready, interactive=lecture_tts_ready), |
| gr.update(visible=exam_picker_visible), |
| gr.update(value=build_exam_chat_html(state), visible=show_exam_page and (quiz_ready or bool(state.get("exam_chat")))), |
| gr.update(choices=current_choices(state), value=None, interactive=radio_interactive), |
| score_text(state), |
| feedback, |
| gr.update(interactive=submit_interactive), |
| gr.update(interactive=quiz_ready), |
| ) |
|
|
|
|
| def process_pdf(pdf_file: Optional[str], character_id: str, state: Dict[str, Any]): |
| state = new_session_state() |
| state["character_id"] = character_id or DEFAULT_CHARACTER_ID |
| if not pdf_file: |
| state["status"] = "Please upload a PDF first." |
| yield reset_ui_from_state(state, feedback="Upload a PDF to start.", results_visible=False, loading_visible=False) |
| return |
|
|
| state["status"] = "Generating..." |
| yield reset_ui_from_state( |
| state, |
| feedback="Reading the paper and generating lecture/quiz content...", |
| results_visible=False, |
| loading_visible=True, |
| loading_text=_roleplay_loading_text(state.get("character_id") or DEFAULT_CHARACTER_ID, phase="lecture"), |
| ) |
| try: |
| result = engine.build_lecture(pdf_file, get_character_config(state["character_id"])) |
| lecture_text = result["lecture_text"] |
| pdf_excerpt = result["pdf_excerpt"] |
|
|
| state["lecture_text"] = lecture_text |
| state["lecture_audio_path"] = None |
| state["selected_paragraph_idx"] = "" |
| state["explanation_audio_path"] = None |
| state["last_explanation_tts_text"] = "" |
| state["pdf_path"] = pdf_file |
| state["pdf_excerpt"] = pdf_excerpt |
| state["current_page"] = "explain" |
| state["mcqs"] = [] |
| state["current_index"] = 0 |
| state["score"] = 0 |
| state["awaiting_next_after_wrong"] = False |
| state["completed"] = False |
| state["status"] = "Lecture generated." |
| yield reset_ui_from_state( |
| state, |
| feedback=_roleplay_explain_feedback(state.get("character_id") or DEFAULT_CHARACTER_ID), |
| results_visible=True, |
| loading_visible=False, |
| ) |
| except Exception as exc: |
| state["status"] = "Generation failed." |
| state["lecture_text"] = f"Error: {type(exc).__name__}: {exc}" |
| state["current_page"] = "explain" |
| yield reset_ui_from_state( |
| state, |
| feedback=f"Error: {type(exc).__name__}: {exc}", |
| results_visible=True, |
| loading_visible=False, |
| ) |
|
|
|
|
| def submit_answer(choice: Optional[str], state: Dict[str, Any]): |
| if not state.get("mcqs"): |
| state["status"] = "No quiz loaded." |
| return reset_ui_from_state(state, feedback="Upload a PDF and generate lecture first.") |
| if state.get("completed"): |
| return reset_ui_from_state(state, feedback="Quiz already completed.") |
| if not choice: |
| return reset_ui_from_state(state, feedback="Please select an option.") |
|
|
| mcq = get_current_mcq(state) |
| if mcq is None: |
| state["status"] = "No current question." |
| return reset_ui_from_state(state, feedback="No current question.") |
|
|
| _ensure_current_question_in_exam_chat(state) |
| _append_exam_user_answer(state, choice) |
|
|
| selected_label = choice.split(".", 1)[0].strip().upper() |
| correct_label = str(mcq["answer"]).upper() |
| exam_character_id = state.get("exam_character_id") or state.get("character_id") or DEFAULT_CHARACTER_ID |
| q_index = int(state.get("current_index", 0)) |
|
|
| if selected_label == correct_label: |
| state["score"] += 1 |
| state["last_explanation_tts_text"] = "" |
| state["explanation_audio_path"] = None |
| state["awaiting_next_after_wrong"] = False |
| correct_text = exam_feedback_correct(str(exam_character_id), q_index=q_index) |
| state["status"] = correct_text |
| if state["current_index"] >= len(state["mcqs"]) - 1: |
| state["completed"] = True |
| state["status"] = "Quiz completed." |
| total = len(state.get("mcqs") or []) |
| final_comment = exam_feedback_final(str(exam_character_id), score=int(state.get("score", 0)), total=total) |
| _append_exam_assistant_text( |
| state, |
| f"Quiz finished.\nFinal score: {state['score']} / {len(state['mcqs'])}.\n{final_comment}", |
| kind="summary", |
| ) |
| return reset_ui_from_state( |
| state, |
| feedback="", |
| ) |
|
|
| _append_exam_assistant_text(state, correct_text, kind="result") |
| state["current_index"] += 1 |
| _ensure_current_question_in_exam_chat(state) |
| return reset_ui_from_state(state, feedback="") |
|
|
| correct_idx = ["A", "B", "C", "D"].index(correct_label) |
| correct_choice_display = f"{correct_label}. {mcq['options'][correct_idx]}" |
| explanation = normalize_explanation_text(mcq.get("explanation", "")) |
| state["last_explanation_tts_text"] = "" |
| state["explanation_audio_path"] = None |
| state["awaiting_next_after_wrong"] = False |
| incorrect_text = exam_feedback_incorrect( |
| str(exam_character_id), |
| q_index=q_index, |
| correct_choice_display=str(correct_choice_display), |
| explanation=str(explanation or "").strip(), |
| ) |
| state["status"] = incorrect_text.splitlines()[0] if incorrect_text else "Incorrect." |
| _append_exam_assistant_text(state, incorrect_text or "Incorrect.", kind="explanation" if explanation else "result") |
| if state["current_index"] >= len(state["mcqs"]) - 1: |
| state["completed"] = True |
| state["status"] = "Quiz completed." |
| total = len(state.get("mcqs") or []) |
| final_comment = exam_feedback_final(str(exam_character_id), score=int(state.get("score", 0)), total=total) |
| _append_exam_assistant_text( |
| state, |
| f"Quiz finished.\nFinal score: {state['score']} / {len(state['mcqs'])}.\n{final_comment}", |
| kind="summary", |
| ) |
| return reset_ui_from_state(state, feedback="") |
| state["current_index"] += 1 |
| _ensure_current_question_in_exam_chat(state) |
| return reset_ui_from_state(state, feedback="") |
|
|
|
|
| def restart_quiz(state: Dict[str, Any]): |
| if not state.get("mcqs"): |
| return reset_ui_from_state(new_session_state(), feedback="Upload a PDF and generate lecture first.") |
| state["current_index"] = 0 |
| state["score"] = 0 |
| state["awaiting_next_after_wrong"] = False |
| state["completed"] = False |
| state["last_explanation_tts_text"] = "" |
| state["explanation_audio_path"] = None |
| state["exam_chat"] = [] |
| _ensure_current_question_in_exam_chat(state) |
| state["status"] = "Quiz restarted." |
| return reset_ui_from_state(state, feedback="Quiz restarted.") |
|
|
|
|
| def open_exam_picker(state: Dict[str, Any]): |
| if not state.get("lecture_text"): |
| state["status"] = "No lecture loaded." |
| return reset_ui_from_state(state, feedback="Generate lecture first.", results_visible=False, loading_visible=False) |
| state["status"] = "Choose an examiner." |
| state["current_page"] = "explain" |
| return reset_ui_from_state(state, feedback="", results_visible=True, loading_visible=False, exam_picker_visible=True) |
|
|
|
|
| def close_exam_picker(state: Dict[str, Any]): |
| return reset_ui_from_state(state, feedback="") |
|
|
|
|
| def start_exam_mcgonagall(state: Dict[str, Any]): |
| yield from generate_exam_mcq("Mcgonagall", state) |
|
|
|
|
| def start_exam_snape(state: Dict[str, Any]): |
| yield from generate_exam_mcq("snape", state) |
|
|
|
|
| def start_exam(state: Dict[str, Any]): |
| if not state.get("lecture_text"): |
| state["status"] = "No lecture loaded." |
| yield reset_ui_from_state(state, feedback="Generate lecture first.", results_visible=False, loading_visible=False) |
| return |
| character_id = state.get("character_id") or DEFAULT_CHARACTER_ID |
| yield from generate_exam_mcq(character_id, state) |
|
|
|
|
| def generate_exam_mcq(selected_character_id: Optional[str], state: Dict[str, Any]): |
| if not state.get("lecture_text"): |
| state["status"] = "No lecture loaded." |
| yield reset_ui_from_state(state, feedback="Generate lecture first.", results_visible=False, loading_visible=False) |
| return |
| if not selected_character_id: |
| state["status"] = "Please choose an examiner." |
| yield reset_ui_from_state(state, feedback="", results_visible=True, loading_visible=False) |
| return |
|
|
| state["current_page"] = "exam" |
| state["exam_character_id"] = selected_character_id |
| cfg = get_character_config(selected_character_id) |
| display_name = str(cfg.get("display_name", "Professor")) |
| state["status"] = f"{display_name} is preparing your exam..." |
| state["mcq_generating"] = True |
| state["last_explanation_tts_text"] = "" |
| state["explanation_audio_path"] = None |
| state["mcqs"] = [] |
| state["exam_chat"] = [] |
| yield reset_ui_from_state( |
| state, |
| feedback="", |
| results_visible=True, |
| loading_visible=True, |
| loading_text=_roleplay_loading_text(selected_character_id, phase="exam"), |
| ) |
|
|
| try: |
| pdf_path = state.get("pdf_path") |
| pdf_excerpt = state.get("pdf_excerpt", "") |
| if not pdf_path: |
| raise RuntimeError("PDF path missing in session state.") |
| mcqs = engine.build_mcqs(pdf_path, pdf_excerpt, get_character_config(selected_character_id)) |
| state["mcqs"] = mcqs |
| state["current_index"] = 0 |
| state["score"] = 0 |
| state["awaiting_next_after_wrong"] = False |
| state["completed"] = False |
| state["current_page"] = "exam" |
| state["mcq_generating"] = False |
| _ensure_current_question_in_exam_chat(state) |
| state["status"] = "Exam prepared." |
| yield reset_ui_from_state( |
| state, |
| feedback="", |
| results_visible=True, |
| loading_visible=False, |
| ) |
| except Exception as exc: |
| state["current_page"] = "exam" |
| state["mcq_generating"] = False |
| state["status"] = "Exam generation failed." |
| _append_exam_assistant_text( |
| state, |
| f"Failed to generate exam.\nError: {type(exc).__name__}: {exc}", |
| kind="note", |
| ) |
| yield reset_ui_from_state( |
| state, |
| feedback="", |
| results_visible=True, |
| loading_visible=False, |
| ) |
|
|
|
|
| def on_generate_click(pdf_file: Optional[str], explain_character_id: str, state: Dict[str, Any]): |
| yield from process_pdf(pdf_file, explain_character_id, state) |
|
|
|
|
| def go_to_explain_page(state: Dict[str, Any]): |
| state["current_page"] = "explain" |
| return reset_ui_from_state(state, feedback=state.get("status", "Explain page")) |
|
|
|
|
| def on_character_change(character_id: str, state: Dict[str, Any]): |
| cfg = get_character_config(character_id) |
| if state.get("current_page") == "exam": |
| state["exam_character_id"] = cfg["id"] |
| loading_on = bool(state.get("mcq_generating")) |
| loading_text = _roleplay_loading_text(cfg["id"], phase="exam") if loading_on else "" |
| return ( |
| state, |
| build_character_header_html(cfg["id"]), |
| build_chat_avatar_html(cfg["id"]), |
| build_chat_meta_html(cfg["id"]), |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(value=build_loading_html(loading_text), visible=loading_on), |
| state.get("status", "Exam"), |
| ) |
| state["character_id"] = cfg["id"] |
| state["current_page"] = "explain" |
| state["lecture_audio_path"] = None |
| state["selected_paragraph_idx"] = "" |
| state["explanation_audio_path"] = None |
| state["last_explanation_tts_text"] = "" |
| |
| return ( |
| state, |
| build_character_header_html(cfg["id"]), |
| build_chat_avatar_html(cfg["id"]), |
| build_chat_meta_html(cfg["id"]), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(value="", visible=False), |
| "Character switched. Upload PDF and click Generate.", |
| ) |
|
|
|
|
| def tts_voice_for_character(character_id: Optional[str]) -> str: |
| cid = (character_id or "").lower() |
| if "mcgonagall" in cid or cid == "mcg": |
| return "female" |
| if "snape" in cid: |
| return "male" |
| return HF_TTS_VOICE |
|
|
|
|
| def play_lecture_audio(state: Dict[str, Any]): |
| if not state.get("lecture_text"): |
| state["status"] = "No lecture text available." |
| return ( |
| state, |
| state["status"], |
| state.get("lecture_audio_path"), |
| "Generate lecture first.", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
| backend = _tts_backend_name() |
| voice = tts_voice_for_character(state.get("character_id")) |
| try: |
| state["status"] = f"Generating full lecture audio ({backend})..." |
| state["lecture_audio_path"] = engine.synthesize_tts(state["lecture_text"], name_prefix="lecture", voice=voice) |
| state["status"] = "Full lecture audio ready." |
| return ( |
| state, |
| state["status"], |
| state["lecture_audio_path"], |
| f"Full lecture audio generated via `{backend}`.", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
| except Exception as exc: |
| state["status"] = "Full lecture audio generation failed." |
| return ( |
| state, |
| state["status"], |
| state.get("lecture_audio_path"), |
| f"TTS error via `{backend}`: {type(exc).__name__}: {exc}", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
|
|
|
|
| def split_lecture_paragraphs(text: str) -> List[str]: |
| s = str(text or "").replace("\r\n", "\n").strip() |
| if not s: |
| return [] |
| pieces = re.split(r"\n\s*\n+", s) |
| paragraphs = [p.strip() for p in pieces if p and p.strip()] |
| |
| |
| if len(paragraphs) <= 1: |
| fallback_chunks = split_text_every_two_sentences(s, max_len=420) |
| if len(fallback_chunks) > 1: |
| return [c.strip() for c in fallback_chunks if c and c.strip()] |
| return paragraphs |
|
|
|
|
| def paragraph_picker_choices(lecture_text: str) -> List[tuple[str, str]]: |
| paragraphs = split_lecture_paragraphs(lecture_text) |
| choices: List[tuple[str, str]] = [] |
| for i, p in enumerate(paragraphs): |
| preview = re.sub(r"\s+", " ", str(p or "")).strip() |
| if len(preview) > 110: |
| preview = preview[:107].rstrip() + "..." |
| choices.append((f"Chunk {i + 1}: {preview}", str(i))) |
| return choices |
|
|
|
|
| def paragraph_picker_idx_from_value(value: Any) -> str: |
| s = str(value or "").strip() |
| if not s: |
| return "" |
| if s.isdigit(): |
| return s |
| m = re.match(r"^\s*(\d+)\s*[\.、::-]", s) |
| if not m: |
| return "" |
| return str(max(0, int(m.group(1)) - 1)) |
|
|
|
|
| def paragraph_picker_value_for_idx(lecture_text: str, idx: str) -> Optional[str]: |
| try: |
| i = int(str(idx or "").strip()) |
| except Exception: |
| return None |
| paragraphs = split_lecture_paragraphs(lecture_text) |
| if i < 0 or i >= len(paragraphs): |
| return None |
| return str(i) |
|
|
|
|
| def build_clickable_lecture_html(lecture_text: str, selected_idx: str = "") -> str: |
| paragraphs = split_lecture_paragraphs(lecture_text) |
| if not paragraphs: |
| return '<div class="lecture-empty">Generated lecture explanation will appear here...</div>' |
| selected = str(selected_idx or "").strip() |
| parts: List[str] = ['<div class="lecture-clickable">'] |
| for i, p in enumerate(paragraphs): |
| safe = html.escape(p, quote=False).replace("\n", "<br>") |
| selected_cls = " is-selected" if selected and selected == str(i) else "" |
| selected_style = ( |
| "background: #f97316 !important; " |
| "border-color: #f97316 !important; " |
| "box-shadow: 0 0 0 1px rgba(255,255,255,0.16) inset !important; " |
| "color: #ffffff !important;" |
| if selected_cls |
| else "" |
| ) |
| parts.append( |
| f'<div class="lecture-paragraph{selected_cls}" data-idx="{i}" style="{selected_style}">' |
| f'<div class="chunk-text">{safe}</div>' |
| f'</div>' |
| ) |
| parts.append("</div>") |
| return "".join(parts) |
|
|
|
|
| def play_lecture_paragraph_audio(paragraph_idx: str, state: Dict[str, Any]): |
| lecture_text = state.get("lecture_text", "") |
| paragraphs = split_lecture_paragraphs(str(lecture_text or "")) |
| if not paragraphs: |
| state["status"] = "No lecture content available." |
| return ( |
| state, |
| state.get("status", "Idle"), |
| state.get("lecture_audio_path"), |
| "Generate lecture first.", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
|
|
| try: |
| idx = int(str(paragraph_idx or "").strip()) |
| except Exception: |
| idx = -1 |
| if idx < 0 or idx >= len(paragraphs): |
| state["status"] = "Invalid chunk selection." |
| return ( |
| state, |
| state.get("status", "Idle"), |
| state.get("lecture_audio_path"), |
| "Please select a valid chunk.", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
|
|
| backend = _tts_backend_name() |
| voice = tts_voice_for_character(state.get("character_id")) |
| try: |
| state["selected_paragraph_idx"] = str(idx) |
| state["status"] = f"Generating chunk audio ({backend})..." |
| audio_path = engine.synthesize_tts( |
| paragraphs[idx], |
| name_prefix=f"lecture_p{idx+1}", |
| voice=voice, |
| ) |
| state["lecture_audio_path"] = audio_path |
| state["status"] = "Chunk audio ready." |
| char_len = len(paragraphs[idx]) |
| return ( |
| state, |
| state["status"], |
| audio_path, |
| f"Generated chunk {idx+1}/{len(paragraphs)} ({char_len} chars). You can play it below.", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
| except Exception as exc: |
| state["status"] = "Chunk audio generation failed." |
| return ( |
| state, |
| state["status"], |
| state.get("lecture_audio_path"), |
| f"TTS error via `{backend}`: {type(exc).__name__}: {exc}", |
| build_clickable_lecture_html(state.get("lecture_text", ""), str(state.get("selected_paragraph_idx", ""))), |
| ) |
|
|
|
|
| def play_explanation_audio(state: Dict[str, Any]): |
| text = state.get("last_explanation_tts_text", "") |
| if not text: |
| state["status"] = "No explanation available for audio." |
| return state, state["status"], state.get("explanation_audio_path"), "Answer a question first." |
| voice = tts_voice_for_character(state.get("exam_character_id") or state.get("character_id")) |
| try: |
| state["status"] = "Generating explanation audio..." |
| state["explanation_audio_path"] = engine.synthesize_tts(text, name_prefix="explanation", voice=voice) |
| state["status"] = "Explanation audio ready." |
| return state, state["status"], state["explanation_audio_path"], "Explanation audio generated." |
| except Exception as exc: |
| state["status"] = "Explanation audio generation failed." |
| return state, state["status"], state.get("explanation_audio_path"), f"TTS error: {type(exc).__name__}: {exc}" |
|
|
|
|
| def on_play_lecture_audio_click(state: Dict[str, Any]): |
| state, status, audio_path, feedback, lecture_html = play_lecture_audio(state) |
| lecture_text = state.get("lecture_text", "") |
| picker_choices = paragraph_picker_choices(lecture_text) |
| selected_paragraph_value = paragraph_picker_value_for_idx( |
| lecture_text, |
| str(state.get("selected_paragraph_idx", "")).strip(), |
| ) |
| if selected_paragraph_value is None and picker_choices: |
| selected_paragraph_value = picker_choices[0][1] |
| lecture_tts_ready = bool(lecture_text) |
| return ( |
| state, |
| status, |
| audio_path, |
| feedback, |
| lecture_html, |
| gr.update( |
| choices=picker_choices, |
| value=selected_paragraph_value, |
| interactive=lecture_tts_ready, |
| visible=lecture_tts_ready, |
| ), |
| ) |
|
|
|
|
| def on_play_paragraph_click(paragraph_idx: str, state: Dict[str, Any]): |
| idx_value = paragraph_picker_idx_from_value(paragraph_idx) |
| state, status, audio_path, feedback, lecture_html = play_lecture_paragraph_audio(idx_value, state) |
| lecture_text = state.get("lecture_text", "") |
| picker_choices = paragraph_picker_choices(lecture_text) |
| selected_paragraph_value = paragraph_picker_value_for_idx( |
| lecture_text, |
| str(state.get("selected_paragraph_idx", "")).strip(), |
| ) |
| if selected_paragraph_value is None and picker_choices: |
| selected_paragraph_value = picker_choices[0][1] |
| lecture_tts_ready = bool(lecture_text) |
| return ( |
| state, |
| status, |
| audio_path, |
| feedback, |
| lecture_html, |
| gr.update( |
| choices=picker_choices, |
| value=selected_paragraph_value, |
| interactive=lecture_tts_ready, |
| visible=lecture_tts_ready, |
| ), |
| ) |
|
|
|
|
| def build_css() -> str: |
| bg_css = "" |
|
|
| return f""" |
| @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&display=swap'); |
| |
| html, body {{ |
| height: 100%; |
| min-height: 100%; |
| }} |
| body {{ |
| background-color: #ffffff !important; |
| color: #0f172a !important; |
| font-family: "Inter", sans-serif !important; |
| }} |
| .app, #root, .gradio-container, .gradio-container > .main {{ |
| min-height: 100%; |
| background: transparent !important; |
| }} |
| .gradio-container {{ |
| position: relative; |
| z-index: 1; |
| }} |
| .gradio-container .block, |
| .gradio-container .panel, |
| .gradio-container .gr-box, |
| .gradio-container .gr-form, |
| .gradio-container .gr-group {{ |
| background: rgba(14, 16, 24, 0.62) !important; |
| backdrop-filter: blur(2px); |
| border-color: rgba(255, 255, 255, 0.08) !important; |
| }} |
| .gradio-container textarea, |
| .gradio-container input, |
| .gradio-container .wrap, |
| .gradio-container .svelte-1ipelgc {{ |
| background-color: transparent !important; |
| }} |
| .gradio-container textarea, |
| .gradio-container input {{ |
| box-shadow: none !important; |
| color: #eef1f6 !important; |
| }} |
| .gradio-container label, |
| .gradio-container .prose, |
| .gradio-container .prose p, |
| .gradio-container .prose code, |
| .gradio-container .prose strong {{ |
| color: #eef1f6 !important; |
| }} |
| #page-shell {{ |
| min-height: 100%; |
| padding: 2rem 1.2rem 9rem 1.2rem; |
| max-width: 980px; |
| margin: 0 auto; |
| }} |
| #page-shell .hero {{ |
| text-align: center; |
| margin: 1.2rem 0 1.8rem 0; |
| }} |
| #page-shell .hero-title {{ |
| margin: 0; |
| color: #f4f6fb; |
| letter-spacing: 0.01em; |
| font-family: "Instrument Serif", Georgia, serif; |
| font-weight: 400; |
| font-size: clamp(2.05rem, 3vw, 2.75rem); |
| text-shadow: 0 1px 8px rgba(0,0,0,0.35); |
| }} |
| #page-shell .hero-sub {{ |
| margin: 0.65rem 0 0 0; |
| color: rgba(241, 244, 251, 0.88); |
| font-size: 0.98rem; |
| }} |
| #page-shell .hero-note {{ |
| margin-top: 0.5rem; |
| color: rgba(241, 244, 251, 0.72); |
| font-size: 0.92rem; |
| }} |
| #character-card {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| .char-wrap {{ |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 0.45rem; |
| margin-bottom: 0.8rem; |
| }} |
| .char-avatar {{ |
| width: 84px; |
| height: 84px; |
| border-radius: 999px; |
| object-fit: cover; |
| border: 1px solid rgba(255,255,255,0.18); |
| box-shadow: 0 8px 26px rgba(0,0,0,0.28); |
| }} |
| .char-name {{ |
| color: #f6f7fb; |
| font-weight: 600; |
| font-size: 1.05rem; |
| }} |
| .char-tag {{ |
| color: rgba(240,243,250,0.78); |
| font-size: 0.95rem; |
| }} |
| .char-byline {{ |
| color: rgba(240,243,250,0.58); |
| font-size: 0.85rem; |
| }} |
| #character-select-wrap {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| margin: -0.1rem auto 0.8rem auto !important; |
| max-width: 220px !important; |
| min-width: 0 !important; |
| padding: 0 !important; |
| }} |
| #page-shell .flat-select, |
| #page-shell .flat-select > div, |
| #page-shell .flat-select .block, |
| #page-shell .flat-select .gradio-dropdown {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| padding: 0 !important; |
| }} |
| #character-select-wrap, |
| #character-select-wrap > div, |
| #character-select-wrap > div > div, |
| #character-select-wrap .wrap, |
| #character-select-wrap input, |
| #character-select-wrap button {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #character-select-wrap .wrap {{ |
| justify-content: center; |
| padding: 0 !important; |
| min-height: 20px !important; |
| }} |
| #character-select-wrap input, |
| #character-select-wrap [role="combobox"], |
| #character-select-wrap [role="combobox"] {{ |
| font-family: "Inter", sans-serif !important; |
| font-size: 0.88rem !important; |
| font-weight: 400 !important; |
| color: rgba(240,243,250,0.78) !important; |
| text-align: center !important; |
| }} |
| #character-select-wrap [role="combobox"] {{ |
| min-height: 20px !important; |
| padding: 0 !important; |
| }} |
| #character-select-wrap [role="listbox"], |
| [data-testid="dropdown-menu"] {{ |
| background: rgba(20, 22, 30, 0.96) !important; |
| border: 1px solid rgba(255,255,255,0.12) !important; |
| box-shadow: 0 12px 30px rgba(0,0,0,0.35) !important; |
| z-index: 9999 !important; |
| }} |
| [data-testid="dropdown-menu"] * {{ |
| color: #eef1f6 !important; |
| }} |
| #character-select-wrap svg, |
| #character-select-wrap [data-icon] {{ |
| opacity: 0.65 !important; |
| color: rgba(240,243,250,0.78) !important; |
| }} |
| #character-select-wrap {{ |
| display: flex !important; |
| justify-content: center !important; |
| }} |
| #character-select-wrap .wrap {{ |
| display: flex !important; |
| gap: 0.35rem !important; |
| flex-wrap: wrap !important; |
| justify-content: center !important; |
| align-items: center !important; |
| }} |
| #character-select-wrap label {{ |
| background: transparent !important; |
| border: 1px solid rgba(255,255,255,0.14) !important; |
| border-radius: 999px !important; |
| padding: 0 !important; |
| min-height: 42px !important; |
| height: 42px !important; |
| display: inline-flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| line-height: 1 !important; |
| }} |
| #character-select-wrap label span {{ |
| color: rgba(240,243,250,0.78) !important; |
| font-size: 0.88rem !important; |
| display: inline-flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| height: 100% !important; |
| padding: 0 0.8rem !important; |
| line-height: 1 !important; |
| text-align: center !important; |
| }} |
| #character-select-wrap input[type="radio"] {{ |
| display: none !important; |
| }} |
| #character-select-wrap label:has(input[type="radio"]:checked) {{ |
| background: rgba(255,255,255,0.10) !important; |
| border-color: rgba(255,255,255,0.22) !important; |
| }} |
| #character-select-wrap label:has(input[type="radio"]:checked) span {{ |
| color: #ffffff !important; |
| }} |
| #gen-loading {{ |
| text-align: center; |
| padding: 14px 18px; |
| margin: 0 0 12px 0; |
| color: #f2f3f8; |
| background: rgba(255,255,255,0.08); |
| border: 1px solid rgba(255,255,255,0.12); |
| border-radius: 12px; |
| backdrop-filter: blur(3px); |
| }} |
| .gen-loading-inner {{ |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 10px; |
| }} |
| .loader {{ |
| width: 120px; |
| height: 20px; |
| border-radius: 20px; |
| background: linear-gradient(#f97316 0 0) 0/0% no-repeat #93c5fd; |
| animation: l2 2s infinite steps(10); |
| }} |
| @keyframes l2 {{ |
| 100% {{ background-size: 110%; }} |
| }} |
| .gradio-container [data-testid="progress-bar"], |
| .gradio-container [data-testid="progress-bar"] *, |
| .gradio-container .progress-bar, |
| .gradio-container .progress-bar-container, |
| .gradio-container .progress-bar-wrap, |
| .gradio-container .top-progress, |
| .gradio-container .progress {{ |
| display: none !important; |
| }} |
| #results-panel {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| padding: 0 !important; |
| gap: 0.75rem; |
| }} |
| #chat-row {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| align-items: flex-start !important; |
| }} |
| #chat-avatar-col {{ |
| max-width: 54px !important; |
| min-width: 54px !important; |
| }} |
| .mini-avatar {{ |
| width: 34px; |
| height: 34px; |
| border-radius: 999px; |
| object-fit: cover; |
| border: 1px solid rgba(255,255,255,0.16); |
| }} |
| #chat-main {{ |
| flex: 1; |
| }} |
| #chat-meta {{ |
| margin: 0 0 0.45rem 0; |
| color: rgba(245,247,252,0.95); |
| font-size: 0.95rem; |
| font-weight: 600; |
| }} |
| #chat-meta .pill {{ |
| margin-left: 0.5rem; |
| padding: 0.08rem 0.45rem; |
| border-radius: 999px; |
| background: rgba(255,255,255,0.1); |
| color: rgba(255,255,255,0.78); |
| font-size: 0.78rem; |
| }} |
| #lecture-wrap {{ |
| background: rgba(33, 36, 46, 0.82) !important; |
| border: 1px solid rgba(255,255,255,0.06) !important; |
| border-radius: 20px !important; |
| padding: 0.35rem 0.45rem !important; |
| }} |
| #lecture-wrap textarea, |
| #lecture-wrap .prose {{ |
| font-style: italic; |
| line-height: 1.45 !important; |
| color: rgba(244,246,251,0.95) !important; |
| }} |
| #lecture-clickable, |
| #lecture-clickable .html-container, |
| #lecture-clickable .html-container *, |
| #lecture-clickable .lecture-clickable, |
| #lecture-clickable .lecture-clickable * {{ |
| pointer-events: auto !important; |
| opacity: 1 !important; |
| filter: none !important; |
| }} |
| #lecture-clickable .lecture-paragraph {{ |
| cursor: default; |
| pointer-events: auto !important; |
| padding: 10px 12px; |
| border-radius: 14px; |
| margin: 0 0 10px 0; |
| border: 1px solid rgba(255,255,255,0.08); |
| background: rgba(255,255,255,0.04); |
| font-style: italic; |
| line-height: 1.45 !important; |
| color: rgba(244,246,251,0.95) !important; |
| }} |
| #lecture-clickable .chunk-text {{ |
| flex: 1 1 auto; |
| min-width: 0; |
| }} |
| #lecture-clickable .lecture-paragraph:hover {{ |
| background: rgba(255,255,255,0.08); |
| border-color: rgba(255,255,255,0.14); |
| }} |
| #lecture-clickable .lecture-paragraph.is-selected {{ |
| background: #f97316 !important; |
| border-color: #f97316 !important; |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.16) inset !important; |
| color: #ffffff !important; |
| }} |
| #lecture-clickable .lecture-paragraph[data-selected="1"] {{ |
| background: #f97316 !important; |
| border-color: #f97316 !important; |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.16) inset !important; |
| color: #ffffff !important; |
| }} |
| #lecture-wrap [disabled], |
| #lecture-wrap [aria-disabled="true"], |
| #lecture-wrap .disabled, |
| #lecture-wrap .pending, |
| #lecture-wrap .loading, |
| #lecture-wrap .generating {{ |
| opacity: 1 !important; |
| filter: none !important; |
| }} |
| .lecture-empty {{ |
| padding: 10px 12px; |
| color: rgba(244,246,251,0.72); |
| font-style: italic; |
| }} |
| #tts-loading {{ |
| margin: 8px 0 0 0; |
| padding: 10px 12px; |
| border-radius: 14px; |
| border: 1px solid rgba(255,255,255,0.10); |
| background: rgba(255,255,255,0.05); |
| }} |
| .tts-loading-row {{ |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| }} |
| .tts-loading-bar {{ |
| width: 120px; |
| height: 10px; |
| border-radius: 999px; |
| background: linear-gradient(#f97316 0 0) 0/0% no-repeat rgba(147, 197, 253, 0.55); |
| animation: tts_loading 1.6s infinite steps(10); |
| flex: 0 0 auto; |
| }} |
| .tts-loading-text {{ |
| color: rgba(244,246,251,0.85); |
| font-size: 0.92rem; |
| }} |
| #selected-paragraph, |
| #play-paragraph-btn {{ |
| display: none !important; |
| }} |
| #chunk-controls {{ |
| margin-top: 8px !important; |
| align-items: start !important; |
| gap: 8px !important; |
| overflow: visible !important; |
| position: relative !important; |
| z-index: 60 !important; |
| }} |
| #tts-wrap, |
| #paragraph-picker, |
| #paragraph-picker .wrap {{ |
| overflow: visible !important; |
| }} |
| #paragraph-picker .wrap {{ |
| max-height: 320px !important; |
| overflow: auto !important; |
| border: 1px solid rgba(255,255,255,0.10) !important; |
| border-radius: 12px !important; |
| padding: 8px !important; |
| }} |
| #paragraph-picker label {{ |
| border: 1px solid rgba(255,255,255,0.08) !important; |
| border-radius: 10px !important; |
| padding: 8px 10px !important; |
| margin-bottom: 6px !important; |
| background: rgba(255,255,255,0.03) !important; |
| }} |
| #paragraph-picker label:hover {{ |
| background: rgba(255,255,255,0.06) !important; |
| border-color: rgba(255,255,255,0.14) !important; |
| }} |
| #paragraph-picker input[type="radio"]:checked + span {{ |
| color: #f97316 !important; |
| font-weight: 700 !important; |
| }} |
| #play-selected-chunk-btn button {{ |
| min-height: 42px !important; |
| height: 42px !important; |
| border-radius: 999px !important; |
| background: #f97316 !important; |
| border-color: #f97316 !important; |
| color: #ffffff !important; |
| font-weight: 700 !important; |
| font-size: 18px !important; |
| line-height: 1 !important; |
| padding: 0 14px !important; |
| }} |
| #play-selected-chunk-btn button:hover {{ |
| background: #ea580c !important; |
| border-color: #ea580c !important; |
| }} |
| #play-selected-chunk-btn button[disabled] {{ |
| background: #f97316 !important; |
| border-color: #f97316 !important; |
| color: #ffffff !important; |
| opacity: 0.75 !important; |
| }} |
| @keyframes tts_loading {{ |
| 100% {{ background-size: 110%; }} |
| }} |
| #lecture-actions {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| margin-top: 0.35rem !important; |
| }} |
| #exam-entry-wrap {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| margin-top: 0.25rem !important; |
| }} |
| #bottom-composer {{ |
| position: fixed; |
| left: 50%; |
| transform: translateX(-50%); |
| bottom: 18px; |
| width: min(860px, calc(100vw - 28px)); |
| z-index: 40; |
| background: rgba(24, 26, 34, 0.88); |
| border: 1px solid rgba(255,255,255,0.08); |
| border-radius: 999px; |
| box-shadow: 0 16px 40px rgba(0,0,0,0.22); |
| backdrop-filter: blur(10px); |
| padding: 8px 10px; |
| align-items: center !important; |
| gap: 10px !important; |
| }} |
| #bottom-composer .wrap {{ |
| border: none !important; |
| }} |
| #bottom-composer .block {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #bottom-composer button {{ |
| border-radius: 999px !important; |
| }} |
| #generate-btn button {{ |
| min-height: 42px !important; |
| height: 42px !important; |
| padding: 0 18px !important; |
| font-size: 0.9rem !important; |
| line-height: 42px !important; |
| min-width: 132px !important; |
| display: inline-flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| }} |
| #generate-btn .wrap {{ |
| min-height: 42px !important; |
| display: flex !important; |
| align-items: center !important; |
| }} |
| #pdf-uploader {{ |
| min-height: 42px; |
| }} |
| #pdf-uploader .wrap {{ |
| min-height: 42px !important; |
| padding: 4px 10px !important; |
| }} |
| #pdf-uploader [data-testid="file-upload-dropzone"] {{ |
| min-height: 42px !important; |
| height: 42px !important; |
| padding: 2px 8px !important; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| }} |
| #pdf-uploader [data-testid="file-upload-dropzone"] * {{ |
| font-size: 0.88rem !important; |
| }} |
| #status-wrap, #quiz-wrap, #tts-wrap, #explain-wrap {{ |
| background: rgba(18, 20, 28, 0.58) !important; |
| border-radius: 16px !important; |
| }} |
| #exam-page {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| padding: 0 !important; |
| }} |
| #exam-nav {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| justify-content: space-between; |
| align-items: center; |
| }} |
| #exam-chat .exam-chat-wrap {{ |
| width: 100%; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| padding: 0; |
| border-radius: 0; |
| background: transparent; |
| border: none; |
| max-height: 420px; |
| overflow-y: auto; |
| }} |
| #exam-chat .exam-msg {{ |
| display: flex; |
| gap: 10px; |
| align-items: flex-end; |
| }} |
| #exam-chat .exam-msg.user {{ |
| justify-content: flex-end; |
| }} |
| #exam-chat .exam-msg.assistant {{ |
| justify-content: flex-start; |
| }} |
| #exam-chat .exam-chat-avatar {{ |
| width: 34px; |
| height: 34px; |
| border-radius: 999px; |
| object-fit: cover; |
| }} |
| #exam-chat .bubble {{ |
| max-width: 82%; |
| padding: 10px 12px; |
| border-radius: 14px; |
| font-size: 0.95rem; |
| line-height: 1.35; |
| white-space: normal; |
| }} |
| #exam-chat .bubble.assistant {{ |
| background: rgba(255, 255, 255, 0.10); |
| border: 1px solid rgba(255, 255, 255, 0.14); |
| color: rgba(255, 255, 255, 0.95); |
| }} |
| #exam-chat .bubble.user {{ |
| background: rgba(59, 130, 246, 0.22); |
| border: 1px solid rgba(59, 130, 246, 0.28); |
| color: rgba(255, 255, 255, 0.95); |
| }} |
| @media (prefers-color-scheme: light) {{ |
| body {{ |
| background: linear-gradient(180deg, #f5f7fb 0%, #eef2f8 100%) !important; |
| }} |
| .gradio-container .block, |
| .gradio-container .panel, |
| .gradio-container .gr-box, |
| .gradio-container .gr-form, |
| .gradio-container .gr-group {{ |
| background: rgba(255, 255, 255, 0.96) !important; |
| border-color: rgba(15, 23, 42, 0.10) !important; |
| }} |
| .gradio-container textarea, |
| .gradio-container input, |
| .gradio-container label, |
| .gradio-container .prose, |
| .gradio-container .prose p, |
| .gradio-container .prose code, |
| .gradio-container .prose strong {{ |
| color: #0f172a !important; |
| }} |
| .gradio-container .prose span, |
| .gradio-container .prose em, |
| .gradio-container .prose li, |
| .gradio-container .prose a, |
| .gradio-container .prose blockquote, |
| .gradio-container .prose h1, |
| .gradio-container .prose h2, |
| .gradio-container .prose h3, |
| .gradio-container .prose h4, |
| .gradio-container .prose h5, |
| .gradio-container .prose h6 {{ |
| color: #0f172a !important; |
| opacity: 1 !important; |
| }} |
| #lecture-wrap .prose, |
| #lecture-wrap .prose * {{ |
| color: #0f172a !important; |
| opacity: 1 !important; |
| }} |
| #lecture-clickable .lecture-paragraph {{ |
| background: rgba(15, 23, 42, 0.04); |
| border-color: rgba(15, 23, 42, 0.10); |
| color: #0f172a !important; |
| }} |
| #lecture-clickable .lecture-row {{ |
| display: block; |
| }} |
| #lecture-clickable .lecture-paragraph:hover {{ |
| background: rgba(15, 23, 42, 0.06); |
| border-color: rgba(15, 23, 42, 0.16); |
| }} |
| #lecture-clickable .lecture-paragraph.is-selected {{ |
| background: #f97316 !important; |
| border-color: #f97316 !important; |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.18) inset !important; |
| color: #ffffff !important; |
| }} |
| #lecture-clickable .lecture-paragraph[data-selected="1"] {{ |
| background: #f97316 !important; |
| border-color: #f97316 !important; |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.18) inset !important; |
| color: #ffffff !important; |
| }} |
| .lecture-empty {{ |
| color: rgba(15, 23, 42, 0.72); |
| }} |
| #tts-loading {{ |
| border-color: rgba(15, 23, 42, 0.12); |
| background: rgba(15, 23, 42, 0.03); |
| }} |
| .tts-loading-bar {{ |
| background: linear-gradient(#f97316 0 0) 0/0% no-repeat rgba(59, 130, 246, 0.25); |
| }} |
| .tts-loading-text {{ |
| color: rgba(15, 23, 42, 0.78); |
| }} |
| #lecture-wrap .prose code, |
| #lecture-wrap .prose pre {{ |
| color: #0f172a !important; |
| opacity: 1 !important; |
| }} |
| .char-name {{ |
| color: #0f172a !important; |
| }} |
| .char-tag {{ |
| color: rgba(15, 23, 42, 0.78) !important; |
| }} |
| .char-byline {{ |
| color: rgba(15, 23, 42, 0.58) !important; |
| }} |
| #character-select-wrap label {{ |
| border-color: rgba(15, 23, 42, 0.22) !important; |
| background: rgba(255, 255, 255, 0.85) !important; |
| min-height: 42px !important; |
| height: 42px !important; |
| display: inline-flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| }} |
| #character-select-wrap label span {{ |
| color: rgba(15, 23, 42, 0.82) !important; |
| height: 100% !important; |
| display: inline-flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| text-align: center !important; |
| }} |
| #character-select-wrap label:has(input[type="radio"]:checked) {{ |
| background: rgba(15, 23, 42, 0.10) !important; |
| border-color: rgba(15, 23, 42, 0.32) !important; |
| }} |
| #character-select-wrap label:has(input[type="radio"]:checked) span {{ |
| color: #0f172a !important; |
| }} |
| #character-select-wrap svg, |
| #character-select-wrap [data-icon] {{ |
| color: rgba(15, 23, 42, 0.70) !important; |
| }} |
| #chat-meta {{ |
| color: #0f172a !important; |
| background: rgba(255, 255, 255, 0.92) !important; |
| border: 1px solid rgba(15, 23, 42, 0.10) !important; |
| border-radius: 12px !important; |
| padding: 0.45rem 0.7rem !important; |
| }} |
| #chat-meta .pill {{ |
| background: rgba(15, 23, 42, 0.10) !important; |
| color: rgba(15, 23, 42, 0.75) !important; |
| }} |
| #lecture-wrap {{ |
| background: rgba(255, 255, 255, 0.95) !important; |
| border-color: rgba(15, 23, 42, 0.10) !important; |
| }} |
| #lecture-wrap .wrap, |
| #lecture-wrap .block, |
| #lecture-wrap [data-testid="textbox"] {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #lecture-wrap textarea {{ |
| background: #ffffff !important; |
| color: #0f172a !important; |
| border: 1px solid rgba(15, 23, 42, 0.16) !important; |
| border-radius: 10px !important; |
| }} |
| #gen-loading {{ |
| color: #0f172a !important; |
| background: rgba(255, 255, 255, 0.90) !important; |
| border-color: rgba(15, 23, 42, 0.14) !important; |
| }} |
| #gen-loading, |
| #gen-loading *, |
| #gen-loading p, |
| #gen-loading span {{ |
| color: #111827 !important; |
| opacity: 1 !important; |
| }} |
| #bottom-composer {{ |
| background: rgba(255, 255, 255, 0.94) !important; |
| border-color: rgba(15, 23, 42, 0.14) !important; |
| box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16) !important; |
| }} |
| #pdf-uploader [data-testid="file-upload-dropzone"] {{ |
| border-color: rgba(15, 23, 42, 0.20) !important; |
| }} |
| #pdf-uploader [data-testid="file-upload-dropzone"] * {{ |
| color: #0f172a !important; |
| }} |
| #status-wrap, #quiz-wrap, #tts-wrap, #explain-wrap {{ |
| background: #ffffff !important; |
| border: 1px solid rgba(15, 23, 42, 0.10) !important; |
| box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06) !important; |
| }} |
| #status-wrap .block, |
| #quiz-wrap .block, |
| #tts-wrap .block, |
| #explain-wrap .block, |
| #status-wrap .wrap, |
| #quiz-wrap .wrap, |
| #tts-wrap .wrap, |
| #explain-wrap .wrap {{ |
| background: #ffffff !important; |
| border-color: rgba(15, 23, 42, 0.10) !important; |
| box-shadow: none !important; |
| }} |
| #status-wrap textarea, |
| #quiz-wrap textarea, |
| #explain-wrap textarea, |
| #quiz-wrap input, |
| #status-wrap input, |
| #explain-wrap input {{ |
| background: #ffffff !important; |
| color: #0f172a !important; |
| border: 1px solid rgba(15, 23, 42, 0.16) !important; |
| }} |
| #quiz-wrap input[type="radio"] {{ |
| appearance: auto !important; |
| accent-color: #f97316 !important; |
| }} |
| #quiz-wrap input[type="radio"]:checked {{ |
| background-color: #f97316 !important; |
| border-color: #f97316 !important; |
| }} |
| #quiz-wrap label, |
| #quiz-wrap legend, |
| #status-wrap label, |
| #explain-wrap label {{ |
| color: #0f172a !important; |
| }} |
| #quiz-wrap label span, |
| #quiz-wrap [role="radiogroup"] label span {{ |
| color: #0f172a !important; |
| }} |
| #quiz-wrap .prose, |
| #quiz-wrap .prose p, |
| #quiz-wrap .prose span, |
| #quiz-wrap .prose strong, |
| #quiz-wrap .prose em, |
| #quiz-wrap .prose li {{ |
| color: #0f172a !important; |
| opacity: 1 !important; |
| }} |
| #quiz-wrap .prose p {{ |
| color: #1f2937 !important; |
| font-weight: 500 !important; |
| }} |
| #quiz-wrap [role="radiogroup"] label {{ |
| background: #f8fafc !important; |
| border: 1px solid rgba(15, 23, 42, 0.14) !important; |
| }} |
| #exam-chat .exam-chat-wrap {{ |
| background: transparent !important; |
| border: none !important; |
| }} |
| #exam-chat .bubble.assistant {{ |
| background: #f8fafc !important; |
| border: 1px solid rgba(15, 23, 42, 0.12) !important; |
| color: #0f172a !important; |
| }} |
| #exam-chat .bubble.user {{ |
| background: rgba(59, 130, 246, 0.12) !important; |
| border: 1px solid rgba(59, 130, 246, 0.22) !important; |
| color: #0f172a !important; |
| }} |
| #results-panel, |
| #chat-row, |
| #chat-main, |
| #chat-avatar-col {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #chat-row > div, |
| #chat-row .block, |
| #chat-row .wrap, |
| #chat-main .block, |
| #chat-main .wrap, |
| #chat-avatar-col .block, |
| #chat-avatar-col .wrap {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #chat-avatar-col .html-container, |
| #chat-avatar-col .prose {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #exam-nav button {{ |
| border-color: rgba(15, 23, 42, 0.16) !important; |
| }} |
| #exam-picker-overlay {{ |
| position: fixed; |
| inset: 0; |
| z-index: 200; |
| display: none; |
| align-items: center; |
| justify-content: center; |
| background: rgba(2, 6, 23, 0.55); |
| backdrop-filter: blur(6px); |
| padding: 16px; |
| }} |
| #exam-picker-overlay:not(.hide) {{ |
| display: flex; |
| }} |
| #exam-picker-overlay.hide {{ |
| display: none !important; |
| pointer-events: none !important; |
| }} |
| #exam-picker-modal {{ |
| width: min(720px, 94vw); |
| border-radius: 16px; |
| background: #ffffff; |
| border: 1px solid rgba(15, 23, 42, 0.12); |
| box-shadow: 0 18px 50px rgba(15, 23, 42, 0.35); |
| padding: 16px; |
| height: auto !important; |
| max-height: 320px; |
| overflow: hidden; |
| }} |
| #exam-picker-modal .block, |
| #exam-picker-modal .wrap, |
| #exam-picker-modal .panel {{ |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| }} |
| #exam-picker-title {{ |
| font-weight: 700; |
| color: #0f172a; |
| margin-bottom: 10px; |
| }} |
| |
| .exam-picker-grid {{ |
| display: flex !important; |
| flex-wrap: nowrap; |
| gap: 12px; |
| }} |
| .exam-picker-card {{ |
| flex: 1 1 0; |
| min-width: 0 !important; |
| border-radius: 14px; |
| border: 1px solid rgba(15, 23, 42, 0.12); |
| background: #f8fafc; |
| padding: 12px; |
| overflow: hidden; |
| transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease; |
| }} |
| .exam-picker-card:hover {{ |
| transform: translateY(-2px); |
| border-color: rgba(59, 130, 246, 0.35); |
| box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); |
| }} |
| .exam-picker-avatar {{ |
| width: 56px; |
| height: 56px; |
| border-radius: 999px; |
| object-fit: cover; |
| display: block; |
| margin: 0 auto 10px auto; |
| }} |
| .exam-picker-card button {{ |
| width: 100%; |
| }} |
| [data-testid="dropdown-menu"], |
| #character-select-wrap [role="listbox"] {{ |
| background: rgba(255, 255, 255, 0.98) !important; |
| border-color: rgba(15, 23, 42, 0.14) !important; |
| box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18) !important; |
| }} |
| [data-testid="dropdown-menu"] * {{ |
| color: #0f172a !important; |
| }} |
| }} |
| .container {{max-width: 980px; margin: 0 auto;}} |
| .mono {{font-family: ui-monospace, Menlo, Consolas, monospace;}} |
| {bg_css} |
| """ |
|
|
|
|
| CSS = build_css() |
|
|
|
|
| def _image_data_url(path: Path) -> str: |
| if not path.exists(): |
| return "" |
| mime = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" |
| return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii") |
|
|
|
|
| def build_character_header_html(character_id: Optional[str] = None) -> str: |
| cfg = get_character_config(character_id) |
| avatar_url = _image_data_url(Path(cfg.get("avatar_path", ""))) if cfg.get("avatar_path") else "" |
| avatar_img = f'<img class="char-avatar" src="{avatar_url}" alt="avatar" />' if avatar_url else "" |
| return f""" |
| <section class="hero"> |
| <div class="char-wrap"> |
| {avatar_img} |
| <div class="char-name">{cfg.get("display_name", "PDF Paper Tutor")}</div> |
| <div class="char-tag">{cfg.get("tagline", "")}</div> |
| <div class="char-byline">{cfg.get("byline", "")}</div> |
| </div> |
| </section> |
| """ |
|
|
|
|
| def build_chat_avatar_html(character_id: Optional[str] = None) -> str: |
| cfg = get_character_config(character_id) |
| avatar_url = _image_data_url(Path(cfg.get("avatar_path", ""))) if cfg.get("avatar_path") else "" |
| return f'<img class="mini-avatar" src="{avatar_url}" alt="avatar" />' if avatar_url else "" |
|
|
|
|
| def build_chat_meta_html(character_id: Optional[str] = None) -> str: |
| cfg = get_character_config(character_id) |
| return f""" |
| <div id="chat-meta">{cfg.get("chat_label", "PDF Paper Tutor")} <span class="pill">{cfg.get("chat_mode", "paper mode")}</span></div> |
| """ |
|
|
|
|
| def build_exam_picker_avatar_html(character_id: str) -> str: |
| cfg = get_character_config(character_id) |
| avatar_url = _image_data_url(Path(cfg.get("avatar_path", ""))) if cfg.get("avatar_path") else "" |
| avatar_img = f'<img class="exam-picker-avatar" src="{avatar_url}" alt="avatar" />' if avatar_url else "" |
| return f""" |
| <div class="exam-picker-card-inner"> |
| {avatar_img} |
| </div> |
| """ |
|
|
|
|
| with gr.Blocks(css=CSS) as demo: |
| with gr.Column(elem_id="page-shell"): |
| character_header_html = gr.HTML(build_character_header_html(DEFAULT_CHARACTER_ID), elem_id="character-card") |
| character_dropdown = gr.Radio( |
| choices=[(cfg["display_name"], cid) for cid, cfg in CHARACTER_CONFIGS.items()], |
| value=DEFAULT_CHARACTER_ID, |
| label="", |
| show_label=False, |
| interactive=True, |
| elem_id="character-select-wrap", |
| container=False, |
| ) |
|
|
| state = gr.State(new_session_state()) |
|
|
| loading_md = gr.HTML("", elem_id="gen-loading", visible=False) |
| lecture_click_bridge = gr.HTML( |
| "", |
| elem_id="lecture-click-bridge", |
| js_on_load=""" |
| () => { |
| const state = window.__lectureClickTtsGlobal || (window.__lectureClickTtsGlobal = {}); |
| if (state.bound) return; |
| try { |
| const getRoots = () => { |
| const grRoot = (typeof window.gradioApp === "function") ? window.gradioApp() : null; |
| return [ |
| document, |
| grRoot && grRoot.shadowRoot ? grRoot.shadowRoot : null, |
| grRoot, |
| ].filter(Boolean); |
| }; |
| const q = (sel) => { |
| for (const r of getRoots()) { |
| const el = r.querySelector ? r.querySelector(sel) : null; |
| if (el) return el; |
| } |
| return null; |
| }; |
| const showLoading = (text) => { |
| const box = q("#tts-loading"); |
| const t = q("#tts-loading-text"); |
| if (t) t.textContent = text || ""; |
| if (box) { |
| box.style.display = "block"; |
| box.setAttribute("aria-hidden", "false"); |
| } |
| }; |
| const hideLoading = () => { |
| const box = q("#tts-loading"); |
| if (box) { |
| box.style.display = "none"; |
| box.setAttribute("aria-hidden", "true"); |
| } |
| }; |
| const bindAudioLoading = () => { |
| const root = q("#lecture-audio"); |
| const audio = root ? root.querySelector("audio") : q("audio"); |
| if (!audio) return; |
| if (audio.__ttsBound) return; |
| audio.__ttsBound = true; |
| audio.addEventListener("loadstart", () => showLoading("Loading audio..."), true); |
| audio.addEventListener("waiting", () => showLoading("Loading audio..."), true); |
| audio.addEventListener("canplay", () => hideLoading(), true); |
| audio.addEventListener("playing", () => hideLoading(), true); |
| audio.addEventListener("error", () => hideLoading(), true); |
| }; |
| bindAudioLoading(); |
| if (!state.observer) { |
| state.observer = new MutationObserver(() => bindAudioLoading()); |
| state.observer.observe(document.body, { childList: true, subtree: true, attributes: true }); |
| } |
| const selectParagraph = (idx, para, autoPlay) => { |
| const indexText = String(idx ?? "").trim(); |
| const selectedInlineStyle = { |
| background: "#f97316", |
| borderColor: "#f97316", |
| boxShadow: "0 0 0 1px rgba(255,255,255,0.16) inset", |
| color: "#ffffff", |
| }; |
| for (const r of getRoots()) { |
| const rowNodes = r.querySelectorAll ? r.querySelectorAll("#lecture-clickable .lecture-row.is-selected, #lecture-clickable .lecture-row[data-selected='1']") : []; |
| for (const row of rowNodes) { |
| row.classList.remove("is-selected"); |
| row.removeAttribute("data-selected"); |
| } |
| const nodes = r.querySelectorAll ? r.querySelectorAll("#lecture-clickable .lecture-paragraph.is-selected") : []; |
| for (const node of nodes) { |
| node.classList.remove("is-selected"); |
| node.removeAttribute("data-selected"); |
| if (node.style) { |
| node.style.removeProperty("background"); |
| node.style.removeProperty("border-color"); |
| node.style.removeProperty("box-shadow"); |
| node.style.removeProperty("color"); |
| } |
| } |
| } |
| if (para && para.classList) { |
| para.classList.add("is-selected"); |
| para.setAttribute("data-selected", "1"); |
| const row = para.closest ? para.closest(".lecture-row") : null; |
| if (row && row.classList) { |
| row.classList.add("is-selected"); |
| row.setAttribute("data-selected", "1"); |
| } |
| if (para.style) { |
| para.style.setProperty("background", selectedInlineStyle.background, "important"); |
| para.style.setProperty("border-color", selectedInlineStyle.borderColor, "important"); |
| para.style.setProperty("box-shadow", selectedInlineStyle.boxShadow, "important"); |
| para.style.setProperty("color", selectedInlineStyle.color, "important"); |
| } |
| } |
| |
| let input = q("#selected-paragraph textarea, #selected-paragraph input"); |
| if (!input) { |
| const inputWrap = q("#selected-paragraph"); |
| input = inputWrap && inputWrap.querySelector ? inputWrap.querySelector("textarea, input") : null; |
| } |
| if (!input) { |
| showLoading("Chunk selector not found. Please refresh the page."); |
| return; |
| } |
| input.value = indexText; |
| input.dispatchEvent(new Event("input", { bubbles: true })); |
| input.dispatchEvent(new Event("change", { bubbles: true })); |
| |
| if (!autoPlay) return; |
| let btn = q("#play-paragraph-btn button, #play-paragraph-btn"); |
| if (btn && btn.querySelector && btn.tagName !== "BUTTON") { |
| const innerBtn = btn.querySelector("button"); |
| if (innerBtn) btn = innerBtn; |
| } |
| if (!btn) { |
| showLoading("Chunk play control not found. Please refresh the page."); |
| return; |
| } |
| showLoading("Generating audio..."); |
| btn.click(); |
| }; |
| window.__lectureSelectParagraph = (idx, el, autoPlay = true) => { |
| selectParagraph(idx, el, autoPlay); |
| }; |
| |
| const paragraphFromEvent = (e) => { |
| const target = e ? e.target : null; |
| if (target && target.nodeType === 1 && target.closest) { |
| const btn = target.closest(".chunk-play-btn"); |
| if (btn) { |
| const row = btn.closest(".lecture-row"); |
| if (row && row.querySelector) { |
| const p = row.querySelector(".lecture-paragraph"); |
| if (p) return p; |
| } |
| } |
| const p = target.closest(".lecture-paragraph"); |
| if (p) return p; |
| } |
| const path = (e && typeof e.composedPath === "function") ? e.composedPath() : []; |
| for (const n of path) { |
| if (n && n.classList && n.classList.contains("lecture-paragraph")) return n; |
| if (n && n.classList && n.classList.contains("lecture-row") && n.querySelector) { |
| const p = n.querySelector(".lecture-paragraph"); |
| if (p) return p; |
| } |
| } |
| return null; |
| }; |
| |
| const onParagraphClick = (e) => { |
| const para = paragraphFromEvent(e); |
| if (!para) return; |
| if (e && e.target && e.target.closest && e.target.closest(".chunk-play-btn")) { |
| try { e.preventDefault(); } catch (_) {} |
| try { e.stopPropagation(); } catch (_) {} |
| } |
| const idx = para.getAttribute("data-idx"); |
| if (typeof idx !== "string" || idx.trim() === "") return; |
| selectParagraph(idx, para, true); |
| }; |
| const onChunkButtonClick = (e) => { |
| const btn = e && e.target && e.target.closest ? e.target.closest(".chunk-play-btn") : null; |
| if (!btn) return; |
| try { e.preventDefault(); } catch (_) {} |
| try { e.stopPropagation(); } catch (_) {} |
| const row = btn.closest ? btn.closest(".lecture-row") : null; |
| const para = row && row.querySelector ? row.querySelector(".lecture-paragraph") : null; |
| const idx = (btn.getAttribute && btn.getAttribute("data-idx")) || (para && para.getAttribute ? para.getAttribute("data-idx") : ""); |
| if (!para || typeof idx !== "string" || idx.trim() === "") return; |
| selectParagraph(idx, para, true); |
| }; |
| const bindClickRoot = (root) => { |
| if (!root || !root.addEventListener) return; |
| if (root.__lectureClickBound) return; |
| root.__lectureClickBound = true; |
| root.addEventListener("click", onParagraphClick, true); |
| }; |
| const bindParagraphDomHandlers = () => { |
| for (const r of getRoots()) { |
| if (!r || !r.querySelectorAll) continue; |
| const btns = r.querySelectorAll("#lecture-clickable .chunk-play-btn"); |
| for (const btn of btns) { |
| if (btn.__chunkPlayBound) continue; |
| btn.__chunkPlayBound = true; |
| btn.addEventListener("click", onChunkButtonClick, true); |
| } |
| } |
| }; |
| |
| for (const r of getRoots()) bindClickRoot(r); |
| bindClickRoot(window); |
| bindParagraphDomHandlers(); |
| |
| if (!state.rebindObserver) { |
| state.rebindObserver = new MutationObserver(() => { |
| for (const r of getRoots()) bindClickRoot(r); |
| bindParagraphDomHandlers(); |
| }); |
| state.rebindObserver.observe(document.body, { childList: true, subtree: true }); |
| } |
| state.bound = true; |
| } catch (err) { |
| state.bound = false; |
| try { console.error("lecture click bridge failed:", err); } catch (_) {} |
| } |
| } |
| """, |
| ) |
|
|
| with gr.Column(visible=False, elem_id="results-panel") as explain_page: |
| with gr.Row(elem_id="chat-row"): |
| with gr.Column(scale=0, elem_id="chat-avatar-col"): |
| chat_avatar_html = gr.HTML(build_chat_avatar_html(DEFAULT_CHARACTER_ID)) |
| with gr.Column(elem_id="chat-main"): |
| chat_meta_html = gr.HTML(build_chat_meta_html(DEFAULT_CHARACTER_ID)) |
| with gr.Column(elem_id="lecture-wrap"): |
| lecture_box = gr.HTML( |
| build_clickable_lecture_html(""), |
| elem_id="lecture-clickable", |
| ) |
| play_lecture_btn = gr.Button("Play Lecture Audio", interactive=False, visible=False) |
| gr.Markdown("Tip: Select a chunk from the list below (left dot), then click the play button on the right.", elem_id="paragraph-tts-tip") |
| lecture_feedback = gr.Markdown("") |
|
|
| with gr.Column(elem_id="tts-wrap"): |
| lecture_audio = gr.Audio(label="Lecture TTS", type="filepath", elem_id="lecture-audio") |
| gr.HTML( |
| '<div id="tts-loading" aria-hidden="true" style="display:none"><div class="tts-loading-row"><div class="tts-loading-bar"></div><div class="tts-loading-text" id="tts-loading-text">Loading audio...</div></div></div>', |
| ) |
| with gr.Row(elem_id="chunk-controls"): |
| paragraph_picker = gr.Radio( |
| choices=[], |
| value=None, |
| interactive=False, |
| visible=False, |
| label="Select Chunk", |
| elem_id="paragraph-picker", |
| scale=8, |
| ) |
| play_selected_chunk_btn = gr.Button("▶", elem_id="play-selected-chunk-btn", visible=False, interactive=False, scale=1, min_width=52) |
| paragraph_idx = gr.Textbox(value="", label="", show_label=False, elem_id="selected-paragraph") |
| play_paragraph_btn = gr.Button("Play Chunk", elem_id="play-paragraph-btn") |
| with gr.Row(elem_id="exam-entry-wrap"): |
| exam_btn = gr.Button("Go to Exam", interactive=False, variant="secondary", scale=0) |
|
|
| with gr.Column(visible=False, elem_id="exam-picker-overlay") as exam_picker_overlay: |
| with gr.Column(elem_id="exam-picker-modal"): |
| gr.HTML('<div id="exam-picker-title">Choose your examiner</div>') |
| with gr.Row(elem_classes="exam-picker-grid"): |
| with gr.Column(elem_classes="exam-picker-card"): |
| gr.HTML(build_exam_picker_avatar_html("Mcgonagall")) |
| pick_mcg_btn = gr.Button("Mcgonagall", variant="primary") |
| with gr.Column(elem_classes="exam-picker-card"): |
| gr.HTML(build_exam_picker_avatar_html("snape")) |
| pick_snape_btn = gr.Button("Snape", variant="primary") |
| cancel_exam_picker_btn = gr.Button("Cancel", variant="secondary") |
|
|
| with gr.Column(visible=False, elem_id="exam-page") as exam_page: |
| with gr.Row(elem_id="exam-nav"): |
| back_btn = gr.Button("Back", variant="secondary", scale=0) |
| with gr.Column(elem_id="status-wrap", visible=False): |
| status_box = gr.Textbox(label="Status", value="Idle", interactive=False, visible=False) |
| with gr.Column(elem_id="quiz-wrap"): |
| exam_chat = gr.HTML( |
| "", |
| visible=False, |
| elem_id="exam-chat", |
| autoscroll=True, |
| js_on_load=""" |
| () => { |
| const state = window.__examChatAutoScroll || (window.__examChatAutoScroll = {}); |
| const scrollToBottom = (wrap) => { |
| if (!wrap) return; |
| const doScroll = () => { wrap.scrollTop = wrap.scrollHeight; }; |
| doScroll(); |
| requestAnimationFrame(doScroll); |
| setTimeout(doScroll, 50); |
| }; |
| const ensure = () => { |
| const root = document.querySelector('#exam-chat'); |
| const wrap = root ? root.querySelector('.exam-chat-wrap') : null; |
| if (!root || !wrap) return; |
| if (state.wrap === wrap) return; |
| state.wrap = wrap; |
| if (state.wrapObserver) state.wrapObserver.disconnect(); |
| state.wrapObserver = new MutationObserver(() => scrollToBottom(wrap)); |
| state.wrapObserver.observe(wrap, { childList: true, subtree: true, characterData: true }); |
| if (state.rootObserver) state.rootObserver.disconnect(); |
| state.rootObserver = new MutationObserver(() => scrollToBottom(wrap)); |
| state.rootObserver.observe(root, { childList: true, subtree: true, attributes: true }); |
| scrollToBottom(wrap); |
| }; |
| ensure(); |
| if (!state.bodyObserver) { |
| state.bodyObserver = new MutationObserver(() => ensure()); |
| state.bodyObserver.observe(document.body, { childList: true, subtree: true }); |
| } |
| } |
| """, |
| ) |
| choice_radio = gr.Radio(choices=[], label="Select one answer", interactive=False) |
| with gr.Row(): |
| submit_btn = gr.Button("Submit Answer", interactive=False) |
| restart_btn = gr.Button("Restart Quiz", interactive=False) |
| score_box = gr.Textbox(label="Score", value="Score: 0 / 0", interactive=False, visible=False) |
| feedback_box = gr.Textbox(label="Feedback / Explanation", lines=8, interactive=False, visible=False) |
|
|
| with gr.Row(elem_id="bottom-composer"): |
| pdf_input = gr.File( |
| label="", |
| show_label=False, |
| file_types=[".pdf"], |
| type="filepath", |
| elem_id="pdf-uploader", |
| scale=7, |
| min_width=0, |
| ) |
| run_btn = gr.Button("Generate", variant="primary", elem_id="generate-btn", scale=3, min_width=120) |
|
|
| outputs = [ |
| state, |
| character_header_html, |
| character_dropdown, |
| chat_avatar_html, |
| chat_meta_html, |
| loading_md, |
| explain_page, |
| exam_page, |
| status_box, |
| lecture_box, |
| paragraph_picker, |
| lecture_audio, |
| play_lecture_btn, |
| play_selected_chunk_btn, |
| exam_btn, |
| exam_picker_overlay, |
| exam_chat, |
| choice_radio, |
| score_box, |
| feedback_box, |
| submit_btn, |
| restart_btn, |
| ] |
|
|
| run_btn.click(fn=on_generate_click, inputs=[pdf_input, character_dropdown, state], outputs=outputs, show_progress="hidden") |
| character_dropdown.change( |
| fn=on_character_change, |
| inputs=[character_dropdown, state], |
| outputs=[state, character_header_html, chat_avatar_html, chat_meta_html, explain_page, exam_page, loading_md, status_box], |
| ) |
| exam_btn.click(fn=open_exam_picker, inputs=[state], outputs=outputs, show_progress="hidden") |
| pick_mcg_btn.click(fn=start_exam_mcgonagall, inputs=[state], outputs=outputs, show_progress="hidden") |
| pick_snape_btn.click(fn=start_exam_snape, inputs=[state], outputs=outputs, show_progress="hidden") |
| cancel_exam_picker_btn.click(fn=close_exam_picker, inputs=[state], outputs=outputs, show_progress="hidden") |
| back_btn.click(fn=go_to_explain_page, inputs=[state], outputs=outputs, show_progress="hidden") |
| submit_btn.click(fn=submit_answer, inputs=[choice_radio, state], outputs=outputs, show_progress="hidden") |
| restart_btn.click(fn=restart_quiz, inputs=[state], outputs=outputs, show_progress="hidden") |
| play_lecture_btn.click( |
| fn=on_play_lecture_audio_click, |
| inputs=[state], |
| outputs=[state, status_box, lecture_audio, lecture_feedback, lecture_box, paragraph_picker], |
| show_progress="minimal", |
| ) |
| play_paragraph_btn.click( |
| fn=on_play_paragraph_click, |
| inputs=[paragraph_idx, state], |
| outputs=[state, status_box, lecture_audio, lecture_feedback, lecture_box, paragraph_picker], |
| show_progress="minimal", |
| ) |
| play_selected_chunk_btn.click( |
| fn=on_play_paragraph_click, |
| inputs=[paragraph_picker, state], |
| outputs=[state, status_box, lecture_audio, lecture_feedback, lecture_box, paragraph_picker], |
| show_progress="minimal", |
| ) |
|
|
|
|
| demo.queue() |
|
|
| if __name__ == "__main__": |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| css=CSS, |
| ssr_mode=False, |
| ) |
|
|