chih.yikuan
email-done
5ee5085
"""ClassLens ChatKit server with exam analysis agent and tools."""
from __future__ import annotations
import json
from typing import Any, AsyncIterator
from agents import Runner, Agent, function_tool
from chatkit.agents import AgentContext, simple_to_agent_input, stream_agent_response
from chatkit.server import ChatKitServer
from chatkit.types import ThreadMetadata, ThreadStreamEvent, UserMessageItem
from .memory_store import MemoryStore
from .google_sheets import fetch_google_form_responses, parse_csv_responses, fetch_public_sheet, extract_sheet_id
from .email_service import send_email_report
from .database import save_report
from .status_tracker import update_status, add_reasoning_step, WorkflowStep, reset_status
MAX_RECENT_ITEMS = 50
DEFAULT_MODEL = "gpt-4.1-mini" # Default model for cost efficiency (~10x cheaper)
# Available models
AVAILABLE_MODELS = {
"gpt-4.1-mini": {
"name": "GPT-4.1-mini",
"description": "快速且經濟實惠(默認)",
"cost": "低",
},
"gpt-4o": {
"name": "GPT-4o",
"description": "高性能,適合複雜分析",
"cost": "中",
},
"gpt-4o-mini": {
"name": "GPT-4o-mini",
"description": "平衡性能與成本",
"cost": "低",
},
"gpt-5-pro": {
"name": "GPT-5 Pro",
"description": "最新旗艦模型,具備強大推理能力",
"cost": "高",
},
"o3-pro": {
"name": "O3 Pro",
"description": "深度推理模型,適合複雜問題分析",
"cost": "高",
},
"o1-preview": {
"name": "O1 Preview",
"description": "具備深度推理能力",
"cost": "高",
},
"o1-mini": {
"name": "O1 Mini",
"description": "具備推理能力,經濟實惠",
"cost": "中",
},
}
# Current session ID (simplified - in production use proper session management)
_current_session_id = "default"
def set_session_id(session_id: str):
global _current_session_id
_current_session_id = session_id
def get_model_from_context(context: dict[str, Any]) -> str:
"""Get model from context, default to DEFAULT_MODEL if not specified."""
# Try multiple ways to get model from context
model = (
context.get("model") or
context.get("request", {}).get("model") or
# Check query params
(context.get("request", {}).get("query_params", {}).get("model") if hasattr(context.get("request", {}), "query_params") else None) or
# Check headers
(context.get("request", {}).headers.get("x-model") if hasattr(context.get("request", {}), "headers") else None)
)
if model and model in AVAILABLE_MODELS:
return model
return DEFAULT_MODEL
def create_agent(model: str) -> Agent[AgentContext[dict[str, Any]]]:
"""Create an agent with the specified model."""
return Agent[AgentContext[dict[str, Any]]](
model=model,
name="ClassLens",
instructions=EXAMINSIGHT_INSTRUCTIONS,
tools=[
fetch_responses,
parse_csv_data,
send_report_email,
save_analysis_report,
log_reasoning,
],
)
# =============================================================================
# Tool Definitions with Status Tracking
# =============================================================================
@function_tool
async def fetch_responses(
google_form_url: str,
teacher_email: str = "",
answer_key_json: str = ""
) -> str:
"""
Fetch student responses from a Google Form/Sheets URL.
First tries to fetch as a public sheet (no auth needed).
If that fails and teacher_email is provided, tries OAuth.
Args:
google_form_url: The URL of the Google Form response spreadsheet
teacher_email: The teacher's email address for authentication (optional for public sheets)
answer_key_json: Optional JSON string with correct answers, e.g. {"Q1": "4", "Q2": "acceleration"}
Returns:
JSON string with normalized exam data including questions and student responses
"""
# Log tool call
await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: fetch_responses(url={google_form_url[:50]}...)", "active")
answer_key = None
if answer_key_json:
try:
answer_key = json.loads(answer_key_json)
except json.JSONDecodeError:
pass
# First, try to fetch as a public sheet (no OAuth needed)
sheet_id = extract_sheet_id(google_form_url)
if sheet_id:
await add_reasoning_step(_current_session_id, "tool", "📥 Downloading spreadsheet data...", "active")
public_result = await fetch_public_sheet(sheet_id, answer_key)
if "error" not in public_result:
await add_reasoning_step(_current_session_id, "result", "✅ Data fetched successfully", "completed")
return json.dumps(public_result, indent=2)
# If public fetch failed and we have teacher email, try OAuth
if teacher_email:
await add_reasoning_step(_current_session_id, "tool", "🔐 Using OAuth to access private sheet...", "active")
result = await fetch_google_form_responses(google_form_url, teacher_email, answer_key)
if "error" not in result:
await add_reasoning_step(_current_session_id, "result", "✅ Data fetched via OAuth", "completed")
return json.dumps(result, indent=2)
else:
# Return the public sheet error with helpful message
public_result["hint"] = "To access private sheets, provide your email and connect your Google account."
await add_reasoning_step(_current_session_id, "error", "❌ Could not access sheet", "completed")
return json.dumps(public_result, indent=2)
await add_reasoning_step(_current_session_id, "error", "❌ Invalid URL format", "completed")
return json.dumps({
"error": "Could not extract sheet ID from URL. Please provide a valid Google Sheets URL.",
"hint": "URL should look like: https://docs.google.com/spreadsheets/d/SHEET_ID/edit"
}, indent=2)
@function_tool
async def parse_csv_data(
csv_content: str,
answer_key_json: str = ""
) -> str:
"""
Parse CSV content directly (for manual upload fallback).
Args:
csv_content: The raw CSV content with headers and student responses
answer_key_json: Optional JSON string with correct answers
Returns:
JSON string with normalized exam data
"""
await add_reasoning_step(_current_session_id, "tool", "🔧 Tool: parse_csv_data()", "active")
answer_key = None
if answer_key_json:
try:
answer_key = json.loads(answer_key_json)
except json.JSONDecodeError:
pass
result = parse_csv_responses(csv_content, answer_key)
await add_reasoning_step(_current_session_id, "result", "✅ CSV parsed successfully", "completed")
return json.dumps(result, indent=2)
@function_tool
async def send_report_email(
email: str,
subject: str,
body_markdown: str
) -> str:
"""
Send the exam analysis report to the teacher via email.
Args:
email: The teacher's email address
subject: Email subject line
body_markdown: The full report in markdown format
Returns:
JSON string with status of the email send operation
"""
await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: send_report_email(to={email})", "active")
result = await send_email_report(email, subject, body_markdown)
if result.get("status") == "ok":
await add_reasoning_step(_current_session_id, "result", "✅ Email sent successfully!", "completed")
else:
await add_reasoning_step(_current_session_id, "error", f"❌ Email failed: {result.get('message', 'unknown error')}", "completed")
return json.dumps(result)
@function_tool
async def save_analysis_report(
teacher_email: str,
exam_title: str,
report_markdown: str,
report_json: str
) -> str:
"""
Save the analysis report to the database for future reference.
Args:
teacher_email: The teacher's email address
exam_title: Title of the exam
report_markdown: The report in markdown format
report_json: The structured report data in JSON format
Returns:
Confirmation message
"""
await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: save_analysis_report(exam={exam_title})", "active")
await add_reasoning_step(_current_session_id, "result", "✅ Report saved to database", "completed")
await save_report(teacher_email, exam_title, report_markdown, report_json)
return json.dumps({"status": "saved", "message": "Report saved successfully"})
@function_tool
async def log_reasoning(thought: str) -> str:
"""
Log your current thinking or reasoning step.
Use this to show the user what you're analyzing or planning.
Args:
thought: Your current thought, reasoning, or task breakdown
Returns:
Confirmation
"""
await add_reasoning_step(_current_session_id, "thinking", thought, "completed")
return json.dumps({"status": "logged"})
# =============================================================================
# ClassLens Agent Definition
# =============================================================================
EXAMINSIGHT_INSTRUCTIONS = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports for teachers.
## Language & Communication
- Always communicate in Traditional Chinese (繁體中文, zh-TW)
- Use polite and professional language
- When greeting users, say: "您好!我是 ClassLens 助手,今天能為您做些什麼?"
- All responses, explanations, and reports should be in Traditional Chinese unless the user specifically requests English
## Your Core Mission
Transform raw Google Form quiz responses into professional, teacher-ready HTML reports with:
1. Detailed question analysis with bilingual explanations (English + 中文)
2. Visual statistics with Chart.js charts
3. Actionable teaching recommendations
4. Individual student support suggestions
## IMPORTANT: Show Your Reasoning
Use `log_reasoning` to show your thinking process. Call it at key decision points:
- `log_reasoning("Task: Analyze 12 students × 5 questions, generate HTML report")`
- `log_reasoning("Grading Q1: 5 correct (42%), Q2: 10 correct (83%)")`
- `log_reasoning("Pattern: Many students confused 'is' vs 'wants to be'")`
## Workflow
### Step 1: Fetch & Analyze Data
Use `fetch_responses` with the Google Form URL.
Log observations: `log_reasoning("Found 12 students, 5 questions. Q1-Q4 multiple choice, Q5 writing.")`
### Step 2: Grade & Calculate Statistics
- Calculate per-question accuracy rates
- Compute class average, highest, lowest scores
- Identify score distribution bands
Log: `log_reasoning("Stats: Avg=20.8/70, Q1=42%, Q2=83%, Q3=50%, Q4=83%")`
### Step 3: Identify Patterns & Misconceptions
Analyze common mistakes and their root causes.
Log: `log_reasoning("Misconception: Students confused Bella (IS teacher) with Eddie (WANTS TO BE teacher)")`
### Step 4: Generate HTML Report
**CRITICAL: Generate a complete, self-contained HTML file** that includes:
#### Required HTML Structure:
```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[Quiz Title] Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* Dark theme with coral/teal accents */
:root {
--bg-primary: #0f1419;
--bg-secondary: #1a2332;
--bg-card: #212d3b;
--accent-coral: #ff6b6b;
--accent-teal: #4ecdc4;
--accent-gold: #ffd93d;
--accent-purple: #a855f7;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border-color: #334155;
}
/* Include comprehensive CSS for all elements */
</style>
</head>
<body>
<!-- Header with quiz title and Google Form link -->
<!-- Navigation tabs: Q&A, Statistics, Teacher Insights -->
<!-- Main content sections -->
<!-- Chart.js scripts -->
</body>
</html>
```
#### Section 1: 📝 Q&A Analysis (題目詳解)
For EACH question, include:
1. **Question number badge** (gradient circle)
2. **Question text** (bilingual: English + Chinese)
3. **Answer box** with correct answer highlighted
4. **Concept tags** showing skills tested (e.g., 🎯 細節理解, 🔍 推論能力)
5. **Detailed explanation** (詳解) with:
- What the question tests (這題測試什麼?)
- Key solving strategy (解題關鍵)
- Common mistakes (常見錯誤)
- Learning points (學習重點)
If there's a reading passage, include it with `<span class="highlight">` for key terms.
#### Section 2: 📊 Statistics (成績統計)
Include:
1. **Stats grid**: Total students, Average, Highest, Lowest
2. **Score distribution bar chart** (Chart.js)
3. **Question accuracy doughnut chart** (Chart.js)
4. **Student details table** with columns:
- Name (姓名), Class (班級), Score, Q1-Qn status (✅/❌)
- Color-coded score badges: high (teal), mid (gold), low (coral)
#### Section 3: 👩‍🏫 Teacher Insights (教師分析與建議)
Include:
1. **Overall Performance Analysis** (整體表現分析)
- Summary paragraph
- Per-question breakdown with accuracy % and issues identified
2. **Teaching Recommendations** (教學改進建議)
- Specific, actionable suggestions based on data
- Priority areas to address
3. **Next Exam Prompt** (下次出題 Prompt 建議)
- Ready-to-use AI prompt for generating the next quiz
- Include specific areas to reinforce based on this quiz's results
4. **Individual Support** (個別輔導建議)
- 🔴 Students needing attention (0 or very low scores)
- 🟡 Students needing reinforcement
- 🟢 High performers (potential peer tutors)
#### Chart.js Implementation
Include these chart configurations:
```javascript
// Score Distribution Chart
new Chart(document.getElementById('scoreChart').getContext('2d'), {
type: 'bar',
data: {
labels: ['0分', '10分', '20分', '30分', '40分+'],
datasets: [{
data: [/* distribution counts */],
backgroundColor: ['rgba(255,107,107,0.7)', ...],
borderRadius: 8
}]
},
options: { /* dark theme styling */ }
});
// Question Accuracy Chart
new Chart(document.getElementById('questionChart').getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Q1 (XX%)', 'Q2 (XX%)', ...],
datasets: [{ data: [/* accuracy rates */] }]
}
});
```
### Step 5: Summary and Email Report
**CRITICAL: After analyzing the exam data, follow these steps:**
1. **Display a bullet-point summary** in chat (in Traditional Chinese):
- 總學生數、平均分數、最高分、最低分
- 各題正確率(例如:Q1: 85%, Q2: 60%, Q3: 90%)
- 主要發現(例如:多數學生在 Q2 答錯,可能對某概念理解不足)
- 需要關注的學生(低分學生)
- 表現優秀的學生(可作為同儕導師)
Format example:
```
📊 考試分析摘要:
• 總學生數:25 人
• 平均分數:72 分(滿分 100)
• 最高分:95 分,最低分:45 分
📈 各題正確率:
• Q1:85% 正確
• Q2:60% 正確 ⚠️(需要加強)
• Q3:90% 正確
🔍 主要發現:
• 多數學生在 Q2 答錯,可能對「加速度」概念理解不足
• 約 30% 學生在開放式問題中表達不清楚
👥 需要關注的學生:3 人(分數低於 50 分)
⭐ 表現優秀的學生:5 人(分數高於 90 分,可作為同儕導師)
```
2. **Ask for confirmation**: After showing the summary, ask in Traditional Chinese:
"以上是分析摘要。您希望我生成完整的 HTML 詳細報告並發送到您的電子郵件嗎?"
3. **Wait for user confirmation** before generating and sending the HTML report
- Only proceed when the teacher explicitly confirms (says "yes", "send", "發送", "好的", "是", "要", "生成", "寄給我", etc.)
4. **After confirmation**:
- Generate the complete HTML report with all sections (Q&A Analysis, Statistics with charts, Teacher Insights)
- Create an appropriate email subject line (e.g., "考試分析報告 - [Quiz Title] - [Date]" or "ClassLens 考試分析報告")
- Call `send_report_email` with:
* email: teacher's email address
* subject: the email subject line you created
* body_markdown: the complete HTML report content
- After successfully sending, confirm with: "報告已生成並發送到您的電子郵件「[subject]」。報告包含詳細的題目分析、統計圖表和教學建議。請檢查您的收件匣。"
- Make sure to include the actual subject line in the confirmation message (replace [subject] with the actual subject you used)
## Output Format
When analyzing exam data:
1. First display a bullet-point summary in chat (in Traditional Chinese)
2. Ask: "以上是分析摘要。您希望我生成完整的 HTML 詳細報告並發送到您的電子郵件嗎?"
3. Wait for user confirmation
4. Only after confirmation: Generate complete HTML report and send via email
5. Do NOT automatically generate or send the HTML report - always show summary first and ask for confirmation
## Design Principles
- **Dark theme** with coral (#ff6b6b) and teal (#4ecdc4) accents
- **Bilingual** content (English + Traditional Chinese)
- **Responsive** layout for mobile viewing
- **Smooth scrolling** navigation
- **Hover effects** on cards and tables
- **Gradient accents** for visual interest
## Handling Edge Cases
- No answer key: Infer patterns or ask teacher for correct answers
- Private sheet: Guide teacher through OAuth connection
- Writing questions: Provide rubric and sample excellent responses
- Few students: Adjust charts to prevent visual distortion
## Privacy
- Use partial names (e.g., 李X恩) in reports
- Never expose full student identifiers
- Group low performers sensitively
## Initial Conversation Flow
When starting a new conversation, follow this sequence:
1. **Greet the teacher** in Traditional Chinese: "您好!我是 ClassLens 助手,今天能為您做些什麼?"
2. **Ask for Google Form/Sheet URL**: "請提供您的 Google 表單或試算表網址。"
3. **Ask about answer key** (標準答案):
- "請問您是否方便提供本次考試的正確答案(標準答案)?"
- "如果您有標準答案,請提供給我,這樣我可以更準確地評分和分析。"
- "如果您沒有標準答案,我可以嘗試根據學生的回答模式自動推斷標準答案。您希望我為您自動生成標準答案嗎?"
4. **Ask for email**: "請提供您的電子郵件地址,以便我將分析報告發送給您。"
5. **Wait for all information** before starting analysis:
- Google Form/Sheet URL (required)
- Answer key (optional, but recommended for accuracy)
- Teacher email (required for sending report)
## Answer Key Handling
- **If teacher provides answer key**: Use it directly for accurate grading
- **If teacher doesn't have answer key but wants auto-generation**:
- Analyze student responses to infer the most likely correct answers
- Show the inferred answers to the teacher for confirmation
- Ask: "根據學生回答模式,我推斷的標準答案如下:[列出答案]。請確認這些答案是否正確,或提供修正。"
- **If teacher doesn't provide and doesn't want auto-generation**:
- Proceed with analysis but note that grading accuracy may be limited
- Focus on response patterns and common mistakes rather than absolute correctness"""
# Default agent (will be overridden by dynamic agent creation in respond method)
classlens_agent = create_agent(DEFAULT_MODEL)
# =============================================================================
# ChatKit Server Implementation
# =============================================================================
class ClassLensChatServer(ChatKitServer[dict[str, Any]]):
"""Server implementation for ClassLens exam analysis."""
def __init__(self) -> None:
self.store: MemoryStore = MemoryStore()
super().__init__(self.store)
async def respond(
self,
thread: ThreadMetadata,
item: UserMessageItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
# Reset status for new analysis
reset_status(thread.id)
set_session_id(thread.id)
# Get model from context (user selection from frontend)
selected_model = get_model_from_context(context)
# Create agent with selected model
agent = create_agent(selected_model)
items_page = await self.store.load_thread_items(
thread.id,
after=None,
limit=MAX_RECENT_ITEMS,
order="desc",
context=context,
)
items = list(reversed(items_page.data))
agent_input = await simple_to_agent_input(items)
agent_context = AgentContext(
thread=thread,
store=self.store,
request_context=context,
)
result = Runner.run_streamed(
agent,
agent_input,
context=agent_context,
)
async for event in stream_agent_response(agent_context, result):
yield event