Spaces:
Sleeping
Sleeping
| import os | |
| import time | |
| import base64 | |
| from collections import defaultdict | |
| from typing import List, Dict | |
| # Fix: HF Space 可能预装旧版 huggingface_hub,sentence-transformers 需要 is_offline_mode | |
| import huggingface_hub as _hh | |
| if not hasattr(_hh, "is_offline_mode"): | |
| from huggingface_hub.constants import HF_HUB_OFFLINE | |
| _hh.is_offline_mode = lambda: bool(HF_HUB_OFFLINE) | |
| import gradio as gr | |
| from langsmith import Client # LangSmith 客户端 | |
| from config import ( | |
| DEFAULT_MODEL, | |
| DEFAULT_COURSE_TOPICS, | |
| LEARNING_MODES, | |
| DOC_TYPES, | |
| GENAI_COURSES_SPACE, | |
| USE_WEAVIATE_DIRECT, | |
| WEAVIATE_URL, | |
| WEAVIATE_API_KEY, | |
| WEAVIATE_COLLECTION, | |
| ) | |
| from clare_core import ( | |
| update_weaknesses_from_message, | |
| update_cognitive_state_from_message, | |
| render_session_status, | |
| find_similar_past_question, | |
| detect_language, | |
| chat_with_clare, | |
| export_conversation, | |
| generate_quiz_from_history, | |
| get_empty_input_prompt, | |
| summarize_conversation, | |
| ) | |
| from rag_engine import ( | |
| build_rag_chunks_from_file, | |
| retrieve_relevant_chunks, | |
| ) | |
| from syllabus_utils import extract_course_topics_from_file | |
| from tts_podcast import ( | |
| text_to_speech, | |
| build_podcast_script_from_history, | |
| build_podcast_script_from_summary, | |
| generate_podcast_audio, | |
| ) | |
| # ================== Assets ================== | |
| CLARE_LOGO_PATH = "clare_mascot.png" | |
| CLARE_RUN_PATH = "Clare_Run.png" | |
| CLARE_READING_PATH = "Clare_reading.png" # 确保存在 | |
| # ================== Weaviate 直连 / GenAICoursesDB 检索 ================== | |
| _WEAVIATE_EMBED_MODEL = None # 缓存,避免每次请求都加载 sentence-transformers | |
| # ================== Gradio 6.0 格式转换 ================== | |
| def _tuples_to_messages(history): | |
| """将 tuples 格式 [(user, bot), ...] 转换为 messages 格式 [{"role": "user", "content": ...}, ...]""" | |
| if not history: | |
| return [] | |
| messages = [] | |
| for item in history: | |
| if isinstance(item, (list, tuple)) and len(item) == 2: | |
| user_msg, bot_msg = item | |
| messages.append({"role": "user", "content": str(user_msg) if user_msg else ""}) | |
| messages.append({"role": "assistant", "content": str(bot_msg) if bot_msg else ""}) | |
| elif isinstance(item, dict) and "role" in item: | |
| # 已经是 messages 格式 | |
| messages.append(item) | |
| return messages | |
| def _messages_to_tuples(history): | |
| """将 messages 格式转换为 tuples 格式(供 clare_core 使用)""" | |
| if not history: | |
| return [] | |
| # 如果第一个元素是 tuple/list,说明已经是 tuples 格式 | |
| if history and isinstance(history[0], (list, tuple)): | |
| return [tuple(item) if isinstance(item, list) else item for item in history] | |
| # 否则是 messages 格式,需要转换 | |
| tuples = [] | |
| i = 0 | |
| while i < len(history): | |
| if isinstance(history[i], dict) and history[i].get("role") == "user": | |
| user_content = history[i].get("content", "") | |
| if i + 1 < len(history) and isinstance(history[i + 1], dict) and history[i + 1].get("role") == "assistant": | |
| bot_content = history[i + 1].get("content", "") | |
| tuples.append((user_content, bot_content)) | |
| i += 2 | |
| else: | |
| i += 1 | |
| else: | |
| i += 1 | |
| return tuples | |
| def _warmup_weaviate_embed(): | |
| """后台预热 embedding 模型,避免首次检索超时。""" | |
| if not USE_WEAVIATE_DIRECT: | |
| return | |
| import threading | |
| def _run(): | |
| try: | |
| _get_weaviate_embed_model() | |
| print("[ClareVoice] Weaviate embedding 模型已预热") | |
| except Exception as e: | |
| print(f"[ClareVoice] Weaviate 预热失败: {repr(e)}") | |
| t = threading.Thread(target=_run, daemon=True) | |
| t.start() | |
| def _get_weaviate_embed_model(): | |
| """懒加载并缓存 embedding 模型(使用 HF 免费 sentence-transformers,与建索引时一致)。""" | |
| global _WEAVIATE_EMBED_MODEL | |
| if _WEAVIATE_EMBED_MODEL is None: | |
| from llama_index.embeddings.huggingface import HuggingFaceEmbedding | |
| _WEAVIATE_EMBED_MODEL = HuggingFaceEmbedding( | |
| model_name="sentence-transformers/all-MiniLM-L6-v2" | |
| ) | |
| return _WEAVIATE_EMBED_MODEL | |
| def _retrieve_from_weaviate(question: str, top_k: int = 5, timeout_sec: float = 45.0) -> str: | |
| """直接连接 Weaviate Cloud 检索 GENAI 课程。带超时,避免 HF Space 阻塞。""" | |
| if not USE_WEAVIATE_DIRECT or len(question.strip()) < 5: | |
| return "" | |
| import concurrent.futures | |
| def _call(): | |
| try: | |
| import weaviate | |
| from weaviate.classes.init import Auth | |
| from llama_index.core import Settings, VectorStoreIndex | |
| from llama_index.vector_stores.weaviate import WeaviateVectorStore | |
| Settings.embed_model = _get_weaviate_embed_model() | |
| client = weaviate.connect_to_weaviate_cloud( | |
| cluster_url=WEAVIATE_URL, | |
| auth_credentials=Auth.api_key(WEAVIATE_API_KEY), | |
| ) | |
| try: | |
| if not client.is_ready(): | |
| return "" | |
| vs = WeaviateVectorStore( | |
| weaviate_client=client, | |
| index_name=WEAVIATE_COLLECTION, | |
| ) | |
| index = VectorStoreIndex.from_vector_store(vs) | |
| nodes = index.as_retriever(similarity_top_k=top_k).retrieve(question) | |
| return "\n\n---\n\n".join(n.get_content() for n in nodes) if nodes else "" | |
| finally: | |
| client.close() | |
| except Exception as e: | |
| print(f"[weaviate] retrieve failed: {repr(e)}") | |
| return "" | |
| try: | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: | |
| fut = ex.submit(_call) | |
| return fut.result(timeout=timeout_sec) | |
| except concurrent.futures.TimeoutError: | |
| print(f"[weaviate] retrieve timeout after {timeout_sec}s (模型加载或网络较慢)") | |
| return "" | |
| def get_weaviate_status_text() -> str: | |
| """用于 UI 显示的 Weaviate 连接状态。""" | |
| if not USE_WEAVIATE_DIRECT: | |
| return ( | |
| "⚠️ **Weaviate**: 未配置\n\n" | |
| "在 Space 页点击 **Settings → Repository secrets → New secret** 添加:\n" | |
| "- `WEAVIATE_URL` = `https://riiqvgc7tuum6cgwhik9ra.c0.us-west3.gcp.weaviate.cloud`\n" | |
| "- `WEAVIATE_API_KEY` = 你的 Weaviate API Key" | |
| ) | |
| return "✅ **Weaviate**: 已配置 — GENAI 课程检索已启用" | |
| # 启动时后台预热 embedding 模型,减少首次检索超时 | |
| _warmup_weaviate_embed() | |
| def _retrieve_from_genai_courses(question: str, top_k: int = 5, timeout_sec: float = 25.0) -> str: | |
| """调用 GenAICoursesDB Space 的 retrieve 接口(Weaviate 未配置时回退)。""" | |
| if not GENAI_COURSES_SPACE or len(question.strip()) < 5: | |
| return "" | |
| import concurrent.futures | |
| def _call(): | |
| try: | |
| from gradio_client import Client | |
| client = Client(GENAI_COURSES_SPACE) | |
| return (client.predict(question, api_name="/retrieve") or "").strip() | |
| except Exception as e: | |
| print(f"[genai_courses] retrieve failed: {repr(e)}") | |
| return "" | |
| try: | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: | |
| fut = ex.submit(_call) | |
| return fut.result(timeout=timeout_sec) | |
| except concurrent.futures.TimeoutError: | |
| print(f"[genai_courses] retrieve timeout after {timeout_sec}s") | |
| return "" | |
| # ================== Base64 Helper ================== | |
| def image_to_base64(image_path: str) -> str: | |
| if not os.path.exists(image_path): | |
| return "" | |
| with open(image_path, "rb") as img_file: | |
| encoded_string = base64.b64encode(img_file.read()).decode("utf-8") | |
| if image_path.lower().endswith(".png"): | |
| mime = "image/png" | |
| elif image_path.lower().endswith((".jpg", ".jpeg")): | |
| mime = "image/jpeg" | |
| else: | |
| mime = "image/png" | |
| return f"data:{mime};base64,{encoded_string}" | |
| # ================== User Guide Content ================== | |
| USER_GUIDE_SECTIONS = { | |
| "getting_started": """ | |
| Welcome to **Clare — Your Personalized AI Tutor**. | |
| For this controlled experiment, Clare is already pre-loaded with: | |
| 📘 **Module 10 Reading – Responsible AI (Alto, 2024, Chapter 12)** | |
| You do **NOT** need to upload any materials. | |
| You may optionally upload extra files, but Clare will always include the Module 10 reading as core context. | |
| **To begin:** | |
| 1. Log in with your **Student Name + Email/ID** on the right. | |
| 2. Select your **Learning Mode** on the left. | |
| 3. (Optional) Upload additional Module 10 slides / notes at the top. | |
| 4. Ask Clare any question about **Module 10 – Responsible AI**. | |
| """, | |
| "mode_definition": """ | |
| Clare offers different teaching modes to match how you prefer to learn. | |
| ### Concept Explainer | |
| Clear, structured explanations with examples — ideal for learning new topics. | |
| ### Socratic Tutor | |
| Clare asks guiding questions instead of giving direct answers. | |
| Helps you build reasoning and problem-solving skills. | |
| ### Exam Prep / Quiz | |
| Generates short practice questions aligned with your course week. | |
| Useful for self-testing and preparing for exams. | |
| ### Assignment Helper | |
| Helps you interpret assignment prompts, plan structure, and understand requirements. | |
| ❗ Clare does **not** produce full assignment answers (academic integrity). | |
| ### Quick Summary | |
| Gives brief summaries of slides, reading materials, or long questions. | |
| """, | |
| "how_clare_works": """ | |
| Clare combines **course context + learning science + AI reasoning** to generate answers. | |
| For this experiment, Clare always includes: | |
| - Module 10 Reading – Responsible AI (Alto, 2024, Chapter 12) | |
| - Any additional Module 10 files you upload | |
| Clare uses: | |
| - **Learning Mode**: tone, depth, and interaction style. | |
| - **Reinforcement model**: may prioritize concepts you’re likely to forget. | |
| - **Responsible AI principles**: avoids harmful output and preserves academic integrity. | |
| """, | |
| "memory_line": """ | |
| **Memory Line** is a visualization of your *learning reinforcement cycle*. | |
| Based on the **forgetting-curve model**, Clare organizes your review topics into: | |
| - **T+0 (Current Week)** – new concepts | |
| - **T+7** – first spaced review | |
| - **T+14** – reinforcement review | |
| - **T+30** – long-term consolidation | |
| In this experiment, Memory Line should be interpreted as your **Module 10** reinforcement status. | |
| """, | |
| "learning_progress": """ | |
| The Learning Progress Report highlights: | |
| - **Concepts mastered** | |
| - **Concepts in progress** | |
| - **Concepts due for review** | |
| - Your recent **micro-quiz results** | |
| - Suggested **next-step topics** | |
| """, | |
| "how_files": """ | |
| Your uploaded materials help Clare: | |
| - Align explanations with your exact course (here: **Module 10 – Responsible AI**) | |
| - Use terminology consistent with your professor | |
| - Improve factual accuracy | |
| 🔒 **Privacy** | |
| - Files are used only within your session | |
| - They are not kept as permanent training data | |
| Accepted formats: **.docx / .pdf / .pptx** | |
| For this experiment, Clare is **already pre-loaded** with the Module 10 reading. Uploads are optional. | |
| """, | |
| "micro_quiz": """ | |
| The **Micro-Quiz** function provides a: | |
| - 1-minute self-check | |
| - 1–3 questions about **Module 10 – Responsible AI** | |
| - Instant feedback inside the main chat | |
| **How it works:** | |
| 1. Click “Let’s Try (Micro-Quiz)” on the right. | |
| 2. Clare will send the **first quiz question** in the main chat. | |
| 3. Type your answer in the chat box. | |
| 4. Clare will: | |
| - Judge correctness | |
| - Give a brief explanation | |
| - Ask if you want another question | |
| 5. You can continue or say “stop” at any time. | |
| """, | |
| "summarization": """ | |
| Clare can summarize: | |
| - Module 10 reading | |
| - Uploaded slides / notes | |
| - Long conversation threads | |
| """, | |
| "export_conversation": """ | |
| You can export your chat session for: | |
| - Study review | |
| - Exam preparation | |
| - Saving important explanations | |
| Export format: **Markdown / plain text**. | |
| """, | |
| "faq": """ | |
| **Q: Does Clare give assignment answers?** | |
| No. Clare assists with understanding and planning but does **not** generate full solutions. | |
| **Q: Does Clare replace lectures or TA office hours?** | |
| No. Clare supplements your learning by providing on-demand guidance. | |
| **Q: What languages does Clare support?** | |
| Currently: English & 简体中文. | |
| """ | |
| } | |
| # ================== CSS 样式表 ================== | |
| CUSTOM_CSS = """ | |
| /* --- Main Header --- */ | |
| .header-container { padding: 10px 20px; background-color: #ffffff; border-bottom: 2px solid #f3f4f6; margin-bottom: 15px; display: flex; align-items: center; } | |
| /* --- Sidebar Login Panel --- */ | |
| .login-panel { | |
| background-color: #e5e7eb; | |
| padding: 15px; | |
| border-radius: 8px; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| } | |
| .login-panel img { | |
| display: block; | |
| margin: 0 auto 10px auto; | |
| height: 80px; | |
| object-fit: contain; | |
| } | |
| .login-main-btn { | |
| background-color: #ffffff !important; | |
| color: #000 !important; | |
| border: 1px solid #000 !important; | |
| font-weight: bold !important; | |
| } | |
| .logout-btn { | |
| background-color: #6b2828 !important; | |
| color: #fff !important; | |
| border: none !important; | |
| font-weight: bold !important; | |
| } | |
| /* User Guide */ | |
| .main-user-guide { border: none !important; background: transparent !important; box-shadow: none !important; } | |
| .main-user-guide > .label-wrap { border: none !important; background: transparent !important; padding: 10px 0 !important; } | |
| .main-user-guide > .label-wrap span { font-size: 1.3rem !important; font-weight: 800 !important; color: #111827 !important; } | |
| .clean-accordion { border: none !important; background: transparent !important; box-shadow: none !important; margin-bottom: 0px !important; padding: 0 !important; border-radius: 0 !important; } | |
| .clean-accordion > .label-wrap { padding: 8px 5px !important; border: none !important; background: transparent !important; border-bottom: 1px solid #e5e7eb !important; } | |
| .clean-accordion > .label-wrap span { font-size: 0.9rem !important; font-weight: 500 !important; color: #374151 !important; } | |
| .clean-accordion > .label-wrap:hover { background-color: #f9fafb !important; } | |
| /* Action Buttons */ | |
| .action-btn { font-weight: bold !important; font-size: 0.9rem !important; position: relative; overflow: visible !important; } | |
| .action-btn:hover::before { content: "See User Guide for details"; position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%); background-color: #333; color: #fff; padding: 5px 10px; border-radius: 5px; font-size: 12px; white-space: nowrap; z-index: 1000; pointer-events: none; opacity: 0; animation: fadeIn 0.2s forwards; } | |
| .action-btn:hover::after { content: ""; position: absolute; bottom: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: #333 transparent transparent transparent; opacity: 0; animation: fadeIn 0.2s forwards; } | |
| /* Tooltips & Memory Line */ | |
| .html-tooltip { border-bottom: 1px dashed #999; cursor: help; position: relative; } | |
| .html-tooltip:hover::before { content: attr(data-tooltip); position: absolute; bottom: 120%; left: 0; background-color: #333; color: #fff; padding: 5px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; z-index: 100; pointer-events: none; } | |
| .memory-line-box { border: 1px solid #e5e7eb; padding: 12px; border-radius: 8px; background-color: #f9fafb; height: 100%; display: flex; flex-direction: column; justify-content: space-between; } | |
| /* Results Box Style */ | |
| .result-box { border: 1px solid #e5e7eb; background: #ffffff; padding: 10px; border-radius: 8px; height: 100%; } | |
| .result-box .prose { font-size: 0.9rem; } | |
| @keyframes fadeIn { to { opacity: 1; } } | |
| """ | |
| # ========== Preload Module 10 PDF ========== | |
| MODULE10_PATH = "module10_responsible_ai.pdf" | |
| MODULE10_DOC_TYPE = "Literature Review / Paper" | |
| preloaded_topics: List[str] = [] | |
| preloaded_chunks: List[Dict] = [] | |
| if os.path.exists(MODULE10_PATH): | |
| try: | |
| preloaded_topics = extract_course_topics_from_file( | |
| MODULE10_PATH, MODULE10_DOC_TYPE | |
| ) | |
| preloaded_chunks = build_rag_chunks_from_file( | |
| MODULE10_PATH, MODULE10_DOC_TYPE | |
| ) | |
| print("Module 10 PDF preloaded successfully.") | |
| except Exception as e: | |
| print("Module 10 preload failed:", e) | |
| else: | |
| print("Module 10 PDF not found at path:", MODULE10_PATH) | |
| # ===== LangSmith logging ===== | |
| ls_client = Client() | |
| LS_DATASET_NAME = "clare_user_events" | |
| def log_event(data: Dict): | |
| """ | |
| 把日志写入 LangSmith Dataset (clare_user_events) | |
| """ | |
| try: | |
| inputs = { | |
| "question": data.get("question"), | |
| "student_id": data.get("student_id"), | |
| } | |
| # ✅ event_type 等字段作为 metadata,这样在 Dataset 列表里能直接看到 / 过滤 | |
| metadata = {k: v for k, v in data.items() if k not in ("question", "answer")} | |
| ls_client.create_example( | |
| inputs=inputs, | |
| outputs={"answer": data.get("answer")}, | |
| metadata=metadata, | |
| dataset_name=LS_DATASET_NAME, | |
| ) | |
| except Exception as e: | |
| print("LangSmith log failed:", e) | |
| # ===== Reference Formatting Helper ===== | |
| def format_references( | |
| rag_chunks: List[Dict], max_files: int = 2, max_sections_per_file: int = 3 | |
| ) -> str: | |
| # Even when no RAG chunks are used, we still want to be explicit about source of answer. | |
| if not rag_chunks: | |
| return "\n".join( | |
| [ | |
| "**References:**", | |
| "- (No RAG context used. Answer is based on the model's general knowledge. Web search: not used.)", | |
| ] | |
| ) | |
| # Prefer the most relevant chunks if scores are available | |
| chunks = list(rag_chunks or []) | |
| chunks.sort(key=lambda c: float(c.get("_rag_score", 0.0)), reverse=True) | |
| refs_by_file: Dict[str, List[str]] = defaultdict(list) | |
| for chunk in chunks: | |
| file_name = chunk.get("source_file") or "module10_responsible_ai.pdf" | |
| section = chunk.get("section") or "Related section" | |
| score = chunk.get("_rag_score") | |
| # Add a short excerpt for precision | |
| raw = (chunk.get("text") or "").strip().replace("\n", " ") | |
| excerpt = (raw[:120] + "…") if len(raw) > 120 else raw | |
| score_str = f" (score={float(score):.2f})" if score is not None else "" | |
| entry = f"{section}{score_str}" + (f' — "{excerpt}"' if excerpt else "") | |
| if entry not in refs_by_file[file_name]: | |
| refs_by_file[file_name].append(entry) | |
| if not refs_by_file: | |
| return "\n".join( | |
| [ | |
| "**References:**", | |
| "- (No RAG context used. Answer is based on the model's general knowledge. Web search: not used.)", | |
| ] | |
| ) | |
| lines = ["**References (RAG context used):**"] | |
| for i, (file_name, sections) in enumerate(refs_by_file.items()): | |
| if i >= max_files: | |
| break | |
| short_sections = sections[:max_sections_per_file] | |
| if short_sections: | |
| section_str = "; ".join(short_sections) | |
| lines.append(f"- *{file_name}* — {section_str}") | |
| else: | |
| lines.append(f"- *{file_name}*") | |
| if len(lines) == 1: | |
| return "\n".join( | |
| [ | |
| "**References:**", | |
| "- (No RAG context used. Answer is based on the model's general knowledge. Web search: not used.)", | |
| ] | |
| ) | |
| return "\n".join(lines) | |
| def is_academic_query(message: str) -> bool: | |
| if not message: | |
| return False | |
| m = message.strip().lower() | |
| if not m: | |
| return False | |
| m = " ".join(m.split()) | |
| smalltalk_tokens = { | |
| "hi", "hello", "hey", "yo", | |
| "thanks", "thank", "thank you", | |
| "ok", "okay", | |
| "bye", "goodbye", "see you", | |
| "haha", "lol" | |
| } | |
| tokens = m.split() | |
| if "?" not in m and all(t in smalltalk_tokens for t in tokens): | |
| return False | |
| meta_phrases = [ | |
| "who are you", | |
| "what are you", | |
| "what is your name", | |
| "introduce yourself", | |
| "tell me about yourself", | |
| "what can you do", | |
| "how can you help", | |
| "how do you help", | |
| "how do i use", | |
| "how to use this", | |
| "what is this app", | |
| "what is this tool", | |
| "what is clare", | |
| "who is clare", | |
| ] | |
| if any(p in m for p in meta_phrases): | |
| return False | |
| if len(tokens) <= 2 and "?" not in m: | |
| return False | |
| return True | |
| # ================== Gradio App ================== | |
| with gr.Blocks( | |
| title="Clare – Hanbridge AI Teaching Assistant" | |
| ) as demo: | |
| # 全局状态 | |
| course_outline_state = gr.State(preloaded_topics or DEFAULT_COURSE_TOPICS) | |
| weakness_state = gr.State([]) | |
| cognitive_state_state = gr.State({"confusion": 0, "mastery": 0}) | |
| rag_chunks_state = gr.State(preloaded_chunks or []) | |
| last_question_state = gr.State("") | |
| last_answer_state = gr.State("") | |
| user_name_state = gr.State("") | |
| user_id_state = gr.State("") | |
| # ✅ 当前“最近一次回答”是否已经被点赞/点踩(只允许一次) | |
| feedback_used_state = gr.State(False) | |
| # --- Header --- | |
| with gr.Row(elem_classes="header-container"): | |
| with gr.Column(scale=3): | |
| gr.HTML( | |
| f""" | |
| <div style="display:flex; align-items:center; gap: 20px;"> | |
| <img src="{image_to_base64(CLARE_LOGO_PATH)}" style="height: 75px; object-fit: contain;"> | |
| <div style="display:flex; flex-direction:column;"> | |
| <div style="font-size: 32px; font-weight: 800; line-height: 1.1; color: #000;"> | |
| Clare | |
| <span style="font-size: 18px; font-weight: 600; margin-left: 10px;">Your Personalized AI Tutor</span> | |
| </div> | |
| <div style="font-size: 14px; font-style: italic; color: #333; margin-top: 4px;"> | |
| Personalized guidance, review, and intelligent reinforcement | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| # --- Main Layout --- | |
| with gr.Row(): | |
| # === Left Sidebar === | |
| with gr.Column(scale=1, min_width=200): | |
| clear_btn = gr.Button( | |
| "Reset Conversation", variant="stop", interactive=False | |
| ) | |
| gr.Markdown("### Model Settings") | |
| model_name = gr.Textbox( | |
| label="Model", | |
| value="gpt-4.1-mini", | |
| interactive=False, | |
| lines=1, | |
| ) | |
| language_preference = gr.Radio( | |
| choices=["Auto", "English", "简体中文"], | |
| value="Auto", | |
| label="Language", | |
| interactive=False, | |
| ) | |
| learning_mode = gr.Radio( | |
| choices=LEARNING_MODES, | |
| value="Concept Explainer", | |
| label="Learning Mode", | |
| info="See User Guide for mode definition details.", | |
| interactive=False, | |
| ) | |
| gr.Markdown(get_weaviate_status_text()) | |
| with gr.Accordion( | |
| "User Guide", open=True, elem_classes="main-user-guide" | |
| ): | |
| with gr.Accordion( | |
| "Getting Started", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["getting_started"]) | |
| with gr.Accordion( | |
| "Mode Definition", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["mode_definition"]) | |
| with gr.Accordion( | |
| "How Clare Works", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["how_clare_works"]) | |
| with gr.Accordion( | |
| "What is Memory Line", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["memory_line"]) | |
| with gr.Accordion( | |
| "Learning Progress Report", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["learning_progress"]) | |
| with gr.Accordion( | |
| "How Clare Uses Your Files", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["how_files"]) | |
| with gr.Accordion( | |
| "Micro-Quiz", open=False, elem_classes="clean-accordion" | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["micro_quiz"]) | |
| with gr.Accordion( | |
| "Summarization", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["summarization"]) | |
| with gr.Accordion( | |
| "Export Conversation", | |
| open=False, | |
| elem_classes="clean-accordion", | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["export_conversation"]) | |
| with gr.Accordion( | |
| "FAQ", open=False, elem_classes="clean-accordion" | |
| ): | |
| gr.Markdown(USER_GUIDE_SECTIONS["faq"]) | |
| gr.Markdown("---") | |
| gr.Button("System Settings", size="sm", variant="secondary", interactive=False) | |
| gr.HTML( | |
| """ | |
| <div style="font-size: 11px; color: #9ca3af; margin-top: 15px; text-align: left;"> | |
| © 2025 Made by <a href="https://www.linkedin.com/in/qinghua-xia-479199252/" target="_blank" style="color: #6b7280; text-decoration: underline;">Sarah Xia</a> | |
| </div> | |
| """ | |
| ) | |
| # === Center Main === | |
| with gr.Column(scale=3): | |
| gr.Markdown( | |
| """ | |
| <div style="background-color:#f9fafb; padding:10px; border-radius:5px; margin-top:10px; font-size:0.9em; color:#555;"> | |
| ✦ <b>Instruction:</b> This prototype is <b>pre-loaded</b> with <b>Module 10 – Responsible AI (Alto, 2024, Chapter 12)</b>.<br> | |
| ✦ You do <b>not</b> need to upload files (uploads are optional).<br> | |
| ✦ Please log in on the right before chatting with Clare. | |
| </div> | |
| """ | |
| ) | |
| chatbot = gr.Chatbot( | |
| label="", | |
| height=450, | |
| avatar_images=(None, CLARE_LOGO_PATH), | |
| show_label=False, | |
| ) | |
| # Rating bar (last answer) | |
| gr.Markdown("#### Rate Clare’s last answer") | |
| with gr.Row(): | |
| thumb_up_btn = gr.Button( | |
| "👍 Helpful", size="sm", interactive=False | |
| ) | |
| thumb_down_btn = gr.Button( | |
| "👎 Not helpful", size="sm", interactive=False | |
| ) | |
| feedback_toggle_btn = gr.Button( | |
| "Give detailed feedback", size="sm", variant="secondary", interactive=False | |
| ) | |
| feedback_text = gr.Textbox( | |
| label="What worked well or what was wrong?", | |
| placeholder="Optional: describe what you liked / what was confusing or incorrect.", | |
| lines=3, | |
| visible=False, | |
| ) | |
| feedback_submit_btn = gr.Button( | |
| "Submit Feedback", size="sm", variant="primary", visible=False, interactive=False | |
| ) | |
| user_input = gr.Textbox( | |
| label="Your Input", | |
| placeholder="Please log in on the right before asking Clare anything...", | |
| show_label=False, | |
| container=True, | |
| autofocus=False, | |
| interactive=False, | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| syllabus_file = gr.File( | |
| file_types=[".docx", ".pdf", ".pptx"], | |
| file_count="single", | |
| height=160, | |
| label="Upload additional Module 10 file (.docx/.pdf/.pptx) — optional", | |
| interactive=False, | |
| ) | |
| with gr.Column(scale=1): | |
| doc_type = gr.Dropdown( | |
| choices=DOC_TYPES, | |
| value="Syllabus", | |
| label="File type", | |
| container=True, | |
| interactive=False, | |
| ) | |
| gr.HTML("<div style='height:5px'></div>") | |
| docs_btn = gr.Button( | |
| "📂 Loaded Docs", | |
| size="sm", | |
| variant="secondary", | |
| interactive=False, | |
| ) | |
| with gr.Column(scale=2): | |
| with gr.Group(elem_classes="memory-line-box"): | |
| gr.HTML( | |
| f""" | |
| <div style="font-weight:bold; font-size:14px; margin-bottom:5px;"> | |
| <span class="html-tooltip" data-tooltip="See User Guide for explanation">Memory Line</span> | |
| </div> | |
| <div style="position: relative; height: 35px; margin-top: 10px; margin-bottom: 5px;"> | |
| <div style="position: absolute; bottom: 5px; left: 0; width: 100%; height: 8px; background-color: #e5e7eb; border-radius: 4px;"></div> | |
| <div style="position: absolute; bottom: 5px; left: 0; width: 40%; height: 8px; background-color: #8B1A1A; border-radius: 4px 0 0 4px;"></div> | |
| <img src="{image_to_base64(CLARE_RUN_PATH)}" style="position: absolute; left: 36%; bottom: 8px; height: 35px; z-index: 10;"> | |
| </div> | |
| <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| <div style="font-size: 12px; color: #666;">Next Review: T+7</div> | |
| <div style="font-size: 12px; color: #004a99; text-decoration:underline; cursor:pointer;">Report ⬇️</div> | |
| </div> | |
| """ | |
| ) | |
| review_btn = gr.Button( | |
| "Review Now", | |
| size="sm", | |
| variant="primary", | |
| interactive=False, | |
| ) | |
| session_status = gr.Markdown(visible=False) | |
| # === Right Sidebar === | |
| with gr.Column(scale=1, min_width=180): | |
| with gr.Group(elem_classes="login-panel"): | |
| gr.HTML(f"<img src='{image_to_base64(CLARE_READING_PATH)}'>") | |
| with gr.Group(visible=True) as login_state_1: | |
| login_start_btn = gr.Button( | |
| "Student Login", elem_classes="login-main-btn" | |
| ) | |
| with gr.Group(visible=False) as login_state_2: | |
| name_input = gr.Textbox( | |
| label="Student Name", placeholder="Name", container=True | |
| ) | |
| id_input = gr.Textbox( | |
| label="Email/ID", placeholder="ID", container=True | |
| ) | |
| login_confirm_btn = gr.Button( | |
| "Enter", variant="primary", size="sm" | |
| ) | |
| with gr.Group(visible=False) as login_state_3: | |
| student_info_html = gr.HTML() | |
| logout_btn = gr.Button( | |
| "Log out", elem_classes="logout-btn", size="sm" | |
| ) | |
| gr.Markdown("### Actions") | |
| export_btn = gr.Button( | |
| "Export Conversation", size="sm", elem_classes="action-btn", interactive=False | |
| ) | |
| quiz_btn = gr.Button( | |
| "Let's Try (Micro-Quiz)", size="sm", elem_classes="action-btn", interactive=False | |
| ) | |
| summary_btn = gr.Button( | |
| "Summarization", size="sm", elem_classes="action-btn", interactive=False | |
| ) | |
| gr.Markdown("### Listen & Podcast") | |
| tts_btn = gr.Button( | |
| "Listen (TTS)", size="sm", elem_classes="action-btn", interactive=False | |
| ) | |
| podcast_summary_btn = gr.Button( | |
| "Podcast (Summary)", size="sm", elem_classes="action-btn", interactive=False | |
| ) | |
| podcast_chat_btn = gr.Button( | |
| "Podcast (Chat)", size="sm", elem_classes="action-btn", interactive=False | |
| ) | |
| audio_output = gr.Audio(label="Generated Audio", type="filepath", visible=False) | |
| gr.Markdown("### Results") | |
| with gr.Group(elem_classes="result-box"): | |
| result_display = gr.Markdown( | |
| value="Results (export / summary) will appear here...", | |
| label="Generated Content", | |
| ) | |
| # ================== Login Flow ================== | |
| def show_inputs(): | |
| return { | |
| login_state_1: gr.update(visible=False), | |
| login_state_2: gr.update(visible=True), | |
| login_state_3: gr.update(visible=False), | |
| } | |
| login_start_btn.click( | |
| show_inputs, outputs=[login_state_1, login_state_2, login_state_3] | |
| ) | |
| def confirm_login(name, id_val): | |
| if not name or not id_val: | |
| return { | |
| login_state_1: gr.update(), | |
| login_state_2: gr.update(), | |
| login_state_3: gr.update(), | |
| student_info_html: gr.update( | |
| value="<p style='color:red; font-size:12px;'>Please enter both Name and Email/ID to start.</p>" | |
| ), | |
| user_name_state: gr.update(), | |
| user_id_state: gr.update(), | |
| feedback_used_state: False, | |
| user_input: gr.update(interactive=False), | |
| clear_btn: gr.update(interactive=False), | |
| export_btn: gr.update(interactive=False), | |
| quiz_btn: gr.update(interactive=False), | |
| summary_btn: gr.update(interactive=False), | |
| tts_btn: gr.update(interactive=False), | |
| podcast_summary_btn: gr.update(interactive=False), | |
| podcast_chat_btn: gr.update(interactive=False), | |
| syllabus_file: gr.update(interactive=False), | |
| doc_type: gr.update(interactive=False), | |
| review_btn: gr.update(interactive=False), | |
| language_preference: gr.update(interactive=False), | |
| learning_mode: gr.update(interactive=False), | |
| model_name: gr.update(interactive=False), | |
| docs_btn: gr.update(interactive=False), | |
| thumb_up_btn: gr.update(interactive=False, value="👍 Helpful"), | |
| thumb_down_btn: gr.update(interactive=False, value="👎 Not helpful"), | |
| feedback_toggle_btn: gr.update(interactive=False), | |
| feedback_text: gr.update(visible=False, value=""), | |
| feedback_submit_btn: gr.update(interactive=False, visible=False), | |
| } | |
| info_html = f""" | |
| <div style="margin-bottom:10px;"> | |
| <div style="font-weight:bold; font-size:16px;">{name}</div> | |
| <div style="color:#666; font-size:12px;">{id_val}</div> | |
| </div> | |
| """ | |
| return { | |
| login_state_1: gr.update(visible=False), | |
| login_state_2: gr.update(visible=False), | |
| login_state_3: gr.update(visible=True), | |
| student_info_html: gr.update(value=info_html), | |
| user_name_state: name, | |
| user_id_state: id_val, | |
| feedback_used_state: False, | |
| user_input: gr.update( | |
| interactive=True, | |
| placeholder="Ask about Module 10 concepts, Responsible AI, or let Clare test you...", | |
| ), | |
| clear_btn: gr.update(interactive=True), | |
| export_btn: gr.update(interactive=True), | |
| quiz_btn: gr.update(interactive=True), | |
| summary_btn: gr.update(interactive=True), | |
| tts_btn: gr.update(interactive=True), | |
| podcast_summary_btn: gr.update(interactive=True), | |
| podcast_chat_btn: gr.update(interactive=True), | |
| syllabus_file: gr.update(interactive=True), | |
| doc_type: gr.update(interactive=True), | |
| review_btn: gr.update(interactive=True), | |
| language_preference: gr.update(interactive=True), | |
| learning_mode: gr.update(interactive=True), | |
| model_name: gr.update(interactive=False), | |
| docs_btn: gr.update(interactive=True), | |
| # ✅ 登录后仍然不允许点赞点踩,必须“有回答”才解锁 | |
| thumb_up_btn: gr.update(interactive=False, value="👍 Helpful"), | |
| thumb_down_btn: gr.update(interactive=False, value="👎 Not helpful"), | |
| feedback_toggle_btn: gr.update(interactive=True), | |
| feedback_text: gr.update(visible=False, value=""), | |
| feedback_submit_btn: gr.update(interactive=True, visible=False), | |
| } | |
| login_confirm_btn.click( | |
| confirm_login, | |
| inputs=[name_input, id_input], | |
| outputs=[ | |
| login_state_1, | |
| login_state_2, | |
| login_state_3, | |
| student_info_html, | |
| user_name_state, | |
| user_id_state, | |
| feedback_used_state, | |
| user_input, | |
| clear_btn, | |
| export_btn, | |
| quiz_btn, | |
| summary_btn, | |
| tts_btn, | |
| podcast_summary_btn, | |
| podcast_chat_btn, | |
| syllabus_file, | |
| doc_type, | |
| review_btn, | |
| language_preference, | |
| learning_mode, | |
| model_name, | |
| docs_btn, | |
| thumb_up_btn, | |
| thumb_down_btn, | |
| feedback_toggle_btn, | |
| feedback_text, | |
| feedback_submit_btn, | |
| ], | |
| ) | |
| def logout(): | |
| return { | |
| login_state_1: gr.update(visible=True), | |
| login_state_2: gr.update(visible=False), | |
| login_state_3: gr.update(visible=False), | |
| name_input: gr.update(value=""), | |
| id_input: gr.update(value=""), | |
| user_name_state: "", | |
| user_id_state: "", | |
| feedback_used_state: False, | |
| student_info_html: gr.update(value=""), | |
| user_input: gr.update( | |
| value="", | |
| interactive=False, | |
| placeholder="Please log in on the right before asking Clare anything...", | |
| ), | |
| clear_btn: gr.update(interactive=False), | |
| export_btn: gr.update(interactive=False), | |
| quiz_btn: gr.update(interactive=False), | |
| summary_btn: gr.update(interactive=False), | |
| syllabus_file: gr.update(interactive=False), | |
| doc_type: gr.update(interactive=False), | |
| review_btn: gr.update(interactive=False), | |
| language_preference: gr.update(interactive=False), | |
| learning_mode: gr.update(interactive=False), | |
| docs_btn: gr.update(interactive=False), | |
| thumb_up_btn: gr.update(interactive=False, value="👍 Helpful"), | |
| thumb_down_btn: gr.update(interactive=False, value="👎 Not helpful"), | |
| feedback_toggle_btn: gr.update(interactive=False), | |
| feedback_text: gr.update(visible=False, value=""), | |
| feedback_submit_btn: gr.update(interactive=False, visible=False), | |
| } | |
| logout_btn.click( | |
| logout, | |
| outputs=[ | |
| login_state_1, | |
| login_state_2, | |
| login_state_3, | |
| name_input, | |
| id_input, | |
| user_name_state, | |
| user_id_state, | |
| feedback_used_state, | |
| student_info_html, | |
| user_input, | |
| clear_btn, | |
| export_btn, | |
| quiz_btn, | |
| summary_btn, | |
| tts_btn, | |
| podcast_summary_btn, | |
| podcast_chat_btn, | |
| syllabus_file, | |
| doc_type, | |
| review_btn, | |
| language_preference, | |
| learning_mode, | |
| docs_btn, | |
| thumb_up_btn, | |
| thumb_down_btn, | |
| feedback_toggle_btn, | |
| feedback_text, | |
| feedback_submit_btn, | |
| ], | |
| ) | |
| # ================== Main Logic ================== | |
| def update_course_and_rag(file, doc_type_val): | |
| local_topics = preloaded_topics or [] | |
| local_chunks = preloaded_chunks or [] | |
| if file is not None: | |
| try: | |
| topics = extract_course_topics_from_file(file, doc_type_val) | |
| except Exception: | |
| topics = [] | |
| try: | |
| chunks = build_rag_chunks_from_file(file, doc_type_val) | |
| except Exception: | |
| chunks = [] | |
| local_topics = (preloaded_topics or []) + (topics or []) | |
| local_chunks = (preloaded_chunks or []) + (chunks or []) | |
| status_md = ( | |
| f"✅ **Loaded Module 10 base reading + uploaded {doc_type_val} file.**\n\n" | |
| "Both will be used for explanations and quizzes." | |
| ) | |
| else: | |
| status_md = ( | |
| "✅ **Using pre-loaded Module 10 reading only.**\n\n" | |
| "You may optionally upload additional Module 10 materials." | |
| ) | |
| return local_topics, local_chunks, status_md | |
| syllabus_file.change( | |
| update_course_and_rag, | |
| [syllabus_file, doc_type], | |
| [course_outline_state, rag_chunks_state, session_status], | |
| ) | |
| def show_loaded_docs(doc_type_val): | |
| gr.Info( | |
| f"For this experiment, Clare always includes the pre-loaded Module 10 reading.\n" | |
| f"Additional uploaded {doc_type_val} files will be used as supplementary context.", | |
| title="Loaded Documents", | |
| ) | |
| docs_btn.click(show_loaded_docs, inputs=[doc_type]) | |
| def respond( | |
| message, | |
| chat_history, | |
| course_outline, | |
| weaknesses, | |
| cognitive_state, | |
| rag_chunks, | |
| model_name_val, | |
| lang_pref, | |
| mode_val, | |
| doc_type_val, | |
| user_id_val, | |
| feedback_used, | |
| ): | |
| # 未登录:不解锁按钮 | |
| if not user_id_val: | |
| out_msg = ( | |
| "🔒 Please log in with your Student Name and Email/ID on the right " | |
| "before using Clare." | |
| ) | |
| # 转换为 messages 格式 | |
| current_messages = _tuples_to_messages(chat_history) if chat_history and isinstance(chat_history[0], (list, tuple)) else (chat_history or []) | |
| new_history = current_messages + [ | |
| {"role": "user", "content": message}, | |
| {"role": "assistant", "content": out_msg} | |
| ] | |
| new_status = render_session_status( | |
| mode_val or "Concept Explainer", | |
| weaknesses or [], | |
| cognitive_state or {"confusion": 0, "mastery": 0}, | |
| ) | |
| return ( | |
| "", | |
| new_history, | |
| weaknesses, | |
| cognitive_state, | |
| new_status, | |
| "", | |
| "", | |
| feedback_used, | |
| gr.update(interactive=False, value="👍 Helpful"), | |
| gr.update(interactive=False, value="👎 Not helpful"), | |
| ) | |
| resolved_lang = detect_language(message or "", lang_pref) | |
| # 空输入:不改变按钮状态 | |
| if not message or not message.strip(): | |
| new_status = render_session_status( | |
| mode_val or "Concept Explainer", | |
| weaknesses or [], | |
| cognitive_state or {"confusion": 0, "mastery": 0}, | |
| ) | |
| # 确保返回 messages 格式 | |
| history_messages = _tuples_to_messages(chat_history) if chat_history and isinstance(chat_history[0], (list, tuple)) else (chat_history or []) | |
| return ( | |
| "", | |
| history_messages, | |
| weaknesses, | |
| cognitive_state, | |
| new_status, | |
| "", | |
| "", | |
| feedback_used, | |
| gr.update(), | |
| gr.update(), | |
| ) | |
| weaknesses = update_weaknesses_from_message(message, weaknesses or []) | |
| cognitive_state = update_cognitive_state_from_message(message, cognitive_state) | |
| if is_academic_query(message): | |
| rag_context_text, rag_used_chunks = retrieve_relevant_chunks( | |
| message, rag_chunks or [] | |
| ) | |
| # 优先 Weaviate 直连,否则回退 GenAICoursesDB | |
| course_chunks = "" | |
| course_source = "" | |
| if USE_WEAVIATE_DIRECT: | |
| course_chunks = _retrieve_from_weaviate(message) | |
| course_source = "Weaviate Cloud (GENAI COURSES)" | |
| if course_chunks: | |
| print(f"[ClareVoice] Weaviate 检索成功, 约 {len(course_chunks)} 字符") | |
| else: | |
| print("[ClareVoice] Weaviate 检索无结果 (object count 可能为 0,请运行 build_weaviate_index.py)") | |
| elif GENAI_COURSES_SPACE: | |
| course_chunks = _retrieve_from_genai_courses(message) | |
| course_source = "GenAICoursesDB" | |
| if course_chunks and course_source: | |
| rag_context_text = (rag_context_text or "") + "\n\n[来自 GENAI 课程知识库]\n\n" + course_chunks | |
| rag_used_chunks = list(rag_used_chunks or []) + [ | |
| { | |
| "source_file": course_source, | |
| "section": "retrieve (GENAI COURSES dataset)", | |
| "text": (course_chunks[:200] + "…") if len(course_chunks) > 200 else course_chunks, | |
| "_rag_score": 1.0, | |
| } | |
| ] | |
| elif USE_WEAVIATE_DIRECT and course_source: | |
| # 已配置 Weaviate 但无结果(no document):多为 collection 为空或配置不一致 | |
| rag_used_chunks = list(rag_used_chunks or []) + [ | |
| { | |
| "source_file": course_source, | |
| "section": "Weaviate 知识库暂无文档。请先在 GenAICoursesDB Space 或本地运行 build_weaviate_index.py 建索引;或检查 WEAVIATE_URL/API_KEY 与建索引集群一致。详见 docs/WEAVIATE_NO_DOCUMENT.md", | |
| "text": "", | |
| "_rag_score": 0.0, | |
| } | |
| ] | |
| else: | |
| rag_context_text, rag_used_chunks = "", [] | |
| # 转换 chat_history 为 tuples 格式(clare_core 使用) | |
| if not chat_history: | |
| history_tuples = [] | |
| elif isinstance(chat_history[0], dict): | |
| # messages 格式 | |
| history_tuples = _messages_to_tuples(chat_history) | |
| elif isinstance(chat_history[0], (list, tuple)): | |
| # 已经是 tuples 格式 | |
| history_tuples = [tuple(item) if isinstance(item, list) else item for item in chat_history] | |
| else: | |
| history_tuples = [] | |
| start_ts = time.time() | |
| answer, new_history_tuples = chat_with_clare( | |
| message=message, | |
| history=history_tuples, | |
| model_name=model_name_val, | |
| language_preference=resolved_lang, | |
| learning_mode=mode_val, | |
| doc_type=doc_type_val, | |
| course_outline=course_outline, | |
| weaknesses=weaknesses, | |
| cognitive_state=cognitive_state, | |
| rag_context=rag_context_text, | |
| ) | |
| end_ts = time.time() | |
| latency_ms = (end_ts - start_ts) * 1000.0 | |
| # 转换回 messages 格式 | |
| new_history = _tuples_to_messages(new_history_tuples) | |
| # Always be explicit about sources for academic-style queries | |
| ref_text = format_references(rag_used_chunks) if is_academic_query(message) else "" | |
| if ref_text and new_history and len(new_history) >= 2: | |
| # 最后两个应该是 user 和 assistant | |
| if new_history[-1].get("role") == "assistant": | |
| last_assistant = new_history[-1]["content"] | |
| if "References (RAG context used):" not in (last_assistant or ""): | |
| new_history[-1]["content"] = f"{last_assistant}\n\n{ref_text}" | |
| answer = new_history[-1]["content"] | |
| student_id = user_id_val or "ANON" | |
| experiment_id = "RESP_AI_W10" | |
| try: | |
| log_event( | |
| { | |
| "experiment_id": experiment_id, | |
| "student_id": student_id, | |
| "event_type": "chat_turn", | |
| "timestamp": end_ts, | |
| "latency_ms": latency_ms, | |
| "question": message, | |
| "answer": answer, | |
| "model_name": model_name_val, | |
| "language": resolved_lang, | |
| "learning_mode": mode_val, | |
| } | |
| ) | |
| except Exception as e: | |
| print("log_event error:", e) | |
| new_status = render_session_status(mode_val, weaknesses, cognitive_state) | |
| # ✅ 有新回答:重置 feedback_used=False,并解锁按钮(恢复文案) | |
| return ( | |
| "", | |
| new_history, | |
| weaknesses, | |
| cognitive_state, | |
| new_status, | |
| message, | |
| answer, | |
| False, | |
| gr.update(interactive=True, value="👍 Helpful"), | |
| gr.update(interactive=True, value="👎 Not helpful"), | |
| ) | |
| user_input.submit( | |
| respond, | |
| [ | |
| user_input, | |
| chatbot, | |
| course_outline_state, | |
| weakness_state, | |
| cognitive_state_state, | |
| rag_chunks_state, | |
| model_name, | |
| language_preference, | |
| learning_mode, | |
| doc_type, | |
| user_id_state, | |
| feedback_used_state, | |
| ], | |
| [ | |
| user_input, | |
| chatbot, | |
| weakness_state, | |
| cognitive_state_state, | |
| session_status, | |
| last_question_state, | |
| last_answer_state, | |
| feedback_used_state, | |
| thumb_up_btn, | |
| thumb_down_btn, | |
| ], | |
| ) | |
| # ===== Micro-Quiz ===== | |
| def start_micro_quiz( | |
| chat_history, | |
| course_outline, | |
| weaknesses, | |
| cognitive_state, | |
| rag_chunks, | |
| model_name_val, | |
| lang_pref, | |
| mode_val, | |
| doc_type_val, | |
| user_id_val, | |
| ): | |
| if not user_id_val: | |
| gr.Info("Please log in first to start a micro-quiz.", title="Login required") | |
| history_messages = _tuples_to_messages(chat_history) if chat_history and isinstance(chat_history[0], (list, tuple)) else (chat_history or []) | |
| return ( | |
| history_messages, | |
| weaknesses, | |
| cognitive_state, | |
| render_session_status( | |
| mode_val or "Concept Explainer", | |
| weaknesses or [], | |
| cognitive_state or {"confusion": 0, "mastery": 0}, | |
| ), | |
| ) | |
| quiz_instruction = ( | |
| "We are running a short micro-quiz session based ONLY on **Module 10 – " | |
| "Responsible AI (Alto, 2024, Chapter 12)** and the pre-loaded materials.\n\n" | |
| "Step 1 – Before asking any content question:\n" | |
| "• First ask me which quiz style I prefer right now:\n" | |
| " - (1) Multiple-choice questions\n" | |
| " - (2) Short-answer / open-ended questions\n" | |
| "• Ask me explicitly: \"Which quiz style do you prefer now: 1) Multiple-choice or 2) Short-answer? " | |
| "Please reply with 1 or 2.\"\n" | |
| "• Do NOT start a content question until I have answered 1 or 2.\n\n" | |
| "Step 2 – After I choose the style:\n" | |
| "• If I choose 1 (multiple-choice):\n" | |
| " - Ask ONE multiple-choice question at a time, based on Module 10 concepts " | |
| "(Responsible AI definition, risk types, mitigation layers, EU AI Act, etc.).\n" | |
| " - Provide 3–4 options (A, B, C, D) and make only one option clearly correct.\n" | |
| "• If I choose 2 (short-answer):\n" | |
| " - Ask ONE short-answer question at a time, also based on Module 10 concepts.\n" | |
| " - Do NOT show the answer when you ask the question.\n\n" | |
| "Step 3 – For each answer I give:\n" | |
| "• Grade my answer (correct / partially correct / incorrect).\n" | |
| "• Give a brief explanation and the correct answer.\n" | |
| "• Then ask if I want another question of the SAME style.\n" | |
| "• Continue this pattern until I explicitly say to stop.\n\n" | |
| "Please start by asking me which quiz style I prefer (1 = multiple-choice, 2 = short-answer). " | |
| "Do not ask any content question before I choose." | |
| ) | |
| resolved_lang = lang_pref | |
| start_ts = time.time() | |
| quiz_ctx_text, _quiz_ctx_chunks = retrieve_relevant_chunks( | |
| "Module 10 quiz", rag_chunks or [] | |
| ) | |
| # 转换 chat_history 为 tuples 格式 | |
| if not chat_history: | |
| history_tuples = [] | |
| elif isinstance(chat_history[0], dict): | |
| # messages 格式 | |
| history_tuples = _messages_to_tuples(chat_history) | |
| elif isinstance(chat_history[0], (list, tuple)): | |
| # 已经是 tuples 格式 | |
| history_tuples = [tuple(item) if isinstance(item, list) else item for item in chat_history] | |
| else: | |
| history_tuples = [] | |
| answer, new_history_tuples = chat_with_clare( | |
| message=quiz_instruction, | |
| history=history_tuples, | |
| model_name=model_name_val, | |
| language_preference=resolved_lang, | |
| learning_mode=mode_val, | |
| doc_type=doc_type_val, | |
| course_outline=course_outline, | |
| weaknesses=weaknesses, | |
| cognitive_state=cognitive_state, | |
| rag_context=quiz_ctx_text, | |
| ) | |
| end_ts = time.time() | |
| latency_ms = (end_ts - start_ts) * 1000.0 | |
| student_id = user_id_val or "ANON" | |
| experiment_id = "RESP_AI_W10" | |
| try: | |
| log_event( | |
| { | |
| "experiment_id": experiment_id, | |
| "student_id": student_id, | |
| "event_type": "micro_quiz_start", | |
| "timestamp": end_ts, | |
| "latency_ms": latency_ms, | |
| "question": quiz_instruction, | |
| "answer": answer, | |
| "model_name": model_name_val, | |
| "language": resolved_lang, | |
| "learning_mode": mode_val, | |
| } | |
| ) | |
| except Exception as e: | |
| print("log_event error:", e) | |
| # 转换回 messages 格式 | |
| new_history = _tuples_to_messages(new_history_tuples) | |
| new_status = render_session_status(mode_val, weaknesses, cognitive_state) | |
| return new_history, weaknesses, cognitive_state, new_status | |
| quiz_btn.click( | |
| start_micro_quiz, | |
| [ | |
| chatbot, | |
| course_outline_state, | |
| weakness_state, | |
| cognitive_state_state, | |
| rag_chunks_state, | |
| model_name, | |
| language_preference, | |
| learning_mode, | |
| doc_type, | |
| user_id_state, | |
| ], | |
| [chatbot, weakness_state, cognitive_state_state, session_status], | |
| ) | |
| # ===== Feedback Handlers (thumb + detailed) ===== | |
| def show_feedback_box(): | |
| return { | |
| feedback_text: gr.update(visible=True), | |
| feedback_submit_btn: gr.update(visible=True), | |
| } | |
| feedback_toggle_btn.click( | |
| show_feedback_box, | |
| None, | |
| [feedback_text, feedback_submit_btn], | |
| ) | |
| def send_thumb_up( | |
| last_q, | |
| last_a, | |
| user_id_val, | |
| mode_val, | |
| model_name_val, | |
| lang_pref, | |
| feedback_used, | |
| ): | |
| # 没有可评价回答:保持禁用 | |
| if not last_q and not last_a: | |
| print("No last QA to log for thumbs_up.") | |
| return ( | |
| feedback_used, | |
| gr.update(interactive=False, value="👍 Helpful"), | |
| gr.update(interactive=False, value="👎 Not helpful"), | |
| ) | |
| # 已经反馈过:直接禁用 | |
| if feedback_used: | |
| print("Feedback already sent for this answer (thumb_up).") | |
| return ( | |
| feedback_used, | |
| gr.update(interactive=False), | |
| gr.update(interactive=False), | |
| ) | |
| try: | |
| log_event( | |
| { | |
| "experiment_id": "RESP_AI_W10", | |
| "student_id": user_id_val or "ANON", | |
| "event_type": "like", | |
| "timestamp": time.time(), | |
| "question": last_q, | |
| "answer": last_a, | |
| "model_name": model_name_val, | |
| "language": lang_pref, | |
| "learning_mode": mode_val, | |
| } | |
| ) | |
| print("[Feedback] thumbs_up logged to LangSmith.") | |
| except Exception as e: | |
| print("thumb_up log error:", e) | |
| # 点完一次:置 True + 按钮置灰 + 文案 sent | |
| return ( | |
| True, | |
| gr.update(interactive=False, value="👍 Helpful (sent)"), | |
| gr.update(interactive=False), | |
| ) | |
| def send_thumb_down( | |
| last_q, | |
| last_a, | |
| user_id_val, | |
| mode_val, | |
| model_name_val, | |
| lang_pref, | |
| feedback_used, | |
| ): | |
| if not last_q and not last_a: | |
| print("No last QA to log for thumbs_down.") | |
| return ( | |
| feedback_used, | |
| gr.update(interactive=False, value="👍 Helpful"), | |
| gr.update(interactive=False, value="👎 Not helpful"), | |
| ) | |
| if feedback_used: | |
| print("Feedback already sent for this answer (thumb_down).") | |
| return ( | |
| feedback_used, | |
| gr.update(interactive=False), | |
| gr.update(interactive=False), | |
| ) | |
| try: | |
| log_event( | |
| { | |
| "experiment_id": "RESP_AI_W10", | |
| "student_id": user_id_val or "ANON", | |
| "event_type": "dislike", | |
| "timestamp": time.time(), | |
| "question": last_q, | |
| "answer": last_a, | |
| "model_name": model_name_val, | |
| "language": lang_pref, | |
| "learning_mode": mode_val, | |
| } | |
| ) | |
| print("[Feedback] thumbs_down logged to LangSmith.") | |
| except Exception as e: | |
| print("thumb_down log error:", e) | |
| return ( | |
| True, | |
| gr.update(interactive=False), | |
| gr.update(interactive=False, value="👎 Not helpful (sent)"), | |
| ) | |
| thumb_up_btn.click( | |
| send_thumb_up, | |
| [ | |
| last_question_state, | |
| last_answer_state, | |
| user_id_state, | |
| learning_mode, | |
| model_name, | |
| language_preference, | |
| feedback_used_state, | |
| ], | |
| [feedback_used_state, thumb_up_btn, thumb_down_btn], | |
| ) | |
| thumb_down_btn.click( | |
| send_thumb_down, | |
| [ | |
| last_question_state, | |
| last_answer_state, | |
| user_id_state, | |
| learning_mode, | |
| model_name, | |
| language_preference, | |
| feedback_used_state, | |
| ], | |
| [feedback_used_state, thumb_up_btn, thumb_down_btn], | |
| ) | |
| def submit_detailed_feedback( | |
| text, last_q, last_a, user_id_val, mode_val, model_name_val, lang_pref | |
| ): | |
| if not text or not text.strip(): | |
| return gr.update( | |
| value="", | |
| placeholder="Please enter some feedback before submitting.", | |
| ) | |
| try: | |
| log_event( | |
| { | |
| "experiment_id": "RESP_AI_W10", | |
| "student_id": user_id_val or "ANON", | |
| "event_type": "detailed_feedback", | |
| "timestamp": time.time(), | |
| "question": last_q, | |
| "answer": last_a, | |
| "feedback_text": text.strip(), | |
| "model_name": model_name_val, | |
| "language": lang_pref, | |
| "learning_mode": mode_val, | |
| } | |
| ) | |
| print("[Feedback] detailed_feedback logged to LangSmith.") | |
| except Exception as e: | |
| print("detailed_feedback log error:", e) | |
| return gr.update( | |
| value="", | |
| placeholder="Thanks! Your feedback has been recorded.", | |
| ) | |
| feedback_submit_btn.click( | |
| submit_detailed_feedback, | |
| [ | |
| feedback_text, | |
| last_question_state, | |
| last_answer_state, | |
| user_id_state, | |
| learning_mode, | |
| model_name, | |
| language_preference, | |
| ], | |
| [feedback_text], | |
| ) | |
| # ===== Export / Summary ===== | |
| def export_with_format(history, course_outline, mode, weaknesses, cognitive_state): | |
| # 转换 messages 格式为 tuples(export_conversation 期望 tuples) | |
| if not history: | |
| history_tuples = [] | |
| elif isinstance(history[0], dict): | |
| history_tuples = _messages_to_tuples(history) | |
| elif isinstance(history[0], (list, tuple)): | |
| history_tuples = [tuple(item) if isinstance(item, list) else item for item in history] | |
| else: | |
| history_tuples = [] | |
| return export_conversation(history_tuples, course_outline, mode, weaknesses, cognitive_state) | |
| export_btn.click( | |
| export_with_format, | |
| [chatbot, course_outline_state, learning_mode, weakness_state, cognitive_state_state], | |
| [result_display], | |
| ) | |
| summary_btn.click( | |
| lambda h, c, w, cog, m, l: summarize_conversation( | |
| _messages_to_tuples(h) if h and isinstance(h[0], dict) else ( | |
| [tuple(item) if isinstance(item, list) else item for item in h] if h and isinstance(h[0], (list, tuple)) else (h or []) | |
| ), c, w, cog, m, l | |
| ), | |
| [ | |
| chatbot, | |
| course_outline_state, | |
| weakness_state, | |
| cognitive_state_state, | |
| model_name, | |
| language_preference, | |
| ], | |
| [result_display], | |
| ) | |
| # ===== TTS & Podcast ===== | |
| def generate_tts_audio(result_text): | |
| """Generate TTS audio from result_display text.""" | |
| if not result_text or not result_text.strip(): | |
| return None, gr.update(visible=False) | |
| try: | |
| audio_bytes = text_to_speech(result_text) | |
| if not audio_bytes: | |
| return None, gr.update(visible=False) | |
| # Save to temp file for Gradio Audio component | |
| import tempfile | |
| tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") | |
| tmp_file.write(audio_bytes) | |
| tmp_file.close() | |
| return tmp_file.name, gr.update(visible=True) | |
| except Exception as e: | |
| print(f"[tts] error: {repr(e)}") | |
| return None, gr.update(visible=False) | |
| def generate_podcast_from_summary(h, c, w, cog, m, l): | |
| """Generate podcast audio from summary.""" | |
| try: | |
| if not h: | |
| history_tuples = [] | |
| elif isinstance(h[0], dict): | |
| history_tuples = _messages_to_tuples(h) | |
| elif isinstance(h[0], (list, tuple)): | |
| history_tuples = [tuple(item) if isinstance(item, list) else item for item in h] | |
| else: | |
| history_tuples = [] | |
| md = summarize_conversation(history_tuples, c, w, cog, m, l) | |
| script = build_podcast_script_from_summary(md) | |
| audio_bytes = generate_podcast_audio(script) | |
| if not audio_bytes: | |
| return md, None, gr.update(visible=False) | |
| import tempfile | |
| tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") | |
| tmp_file.write(audio_bytes) | |
| tmp_file.close() | |
| return md, tmp_file.name, gr.update(visible=True) | |
| except Exception as e: | |
| print(f"[podcast_summary] error: {repr(e)}") | |
| return f"Error: {repr(e)}", None, gr.update(visible=False) | |
| def generate_podcast_from_chat(h, c, w, cog): | |
| """Generate podcast audio from full conversation.""" | |
| try: | |
| if not h: | |
| history_tuples = [] | |
| elif isinstance(h[0], dict): | |
| history_tuples = _messages_to_tuples(h) | |
| elif isinstance(h[0], (list, tuple)): | |
| history_tuples = [tuple(item) if isinstance(item, list) else item for item in h] | |
| else: | |
| history_tuples = [] | |
| script = build_podcast_script_from_history(history_tuples) | |
| audio_bytes = generate_podcast_audio(script) | |
| if not audio_bytes: | |
| return None, gr.update(visible=False) | |
| import tempfile | |
| tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") | |
| tmp_file.write(audio_bytes) | |
| tmp_file.close() | |
| return tmp_file.name, gr.update(visible=True) | |
| except Exception as e: | |
| print(f"[podcast_chat] error: {repr(e)}") | |
| return None, gr.update(visible=False) | |
| tts_btn.click( | |
| generate_tts_audio, | |
| [result_display], | |
| [audio_output, audio_output], | |
| ) | |
| podcast_summary_btn.click( | |
| generate_podcast_from_summary, | |
| [ | |
| chatbot, | |
| course_outline_state, | |
| weakness_state, | |
| cognitive_state_state, | |
| model_name, | |
| language_preference, | |
| ], | |
| [result_display, audio_output, audio_output], | |
| ) | |
| podcast_chat_btn.click( | |
| generate_podcast_from_chat, | |
| [chatbot, course_outline_state, weakness_state, cognitive_state_state], | |
| [audio_output, audio_output], | |
| ) | |
| # ===== Reset Conversation ===== | |
| def clear_all(): | |
| empty_state = {"confusion": 0, "mastery": 0} | |
| default_status = render_session_status("Concept Explainer", [], empty_state) | |
| return ( | |
| [], | |
| [], | |
| empty_state, | |
| [], | |
| "", | |
| default_status, | |
| "", | |
| "", | |
| False, | |
| gr.update(interactive=False, value="👍 Helpful"), | |
| gr.update(interactive=False, value="👎 Not helpful"), | |
| None, | |
| gr.update(visible=False), | |
| ) | |
| clear_btn.click( | |
| clear_all, | |
| None, | |
| [ | |
| chatbot, | |
| weakness_state, | |
| cognitive_state_state, | |
| rag_chunks_state, | |
| result_display, | |
| session_status, | |
| last_question_state, | |
| last_answer_state, | |
| feedback_used_state, | |
| thumb_up_btn, | |
| thumb_down_btn, | |
| audio_output, | |
| audio_output, | |
| ], | |
| queue=False, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| share=True, | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| css=CUSTOM_CSS, | |
| ) | |