ClareVoiceV1 / app.py
claudqunwang's picture
Fix Weaviate client and HF embedding setup
182c636
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,
)