"""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