Spaces:
Paused
Paused
chih.yikuan Claude Opus 4.6 commited on
Commit ·
bc45e54
1
Parent(s): 6884131
Redesign ClassLens v2: 3-step exam analysis workflow
Browse filesReplace ChatKit-based chat UI with a structured 3-step workflow:
1. Upload & parse exam files (questions, student answers, teacher answers) via GPT Vision
2. Edit analysis prompts with save/load/share functionality
3. Generate and export HTML reports (PDF/PNG)
- Add JWT email/password auth (replace Google OAuth)
- Add file processor with multi-format support (PDF, images, text files)
- Add image-to-text description for visual exam content
- Add model selector (GPT-5.4, 5.3 Instant, 4o, etc.)
- Add per-zone file upload with inline parsed data preview
- Remove ChatKit SDK, Google Sheets, email service dependencies
- Remove .env from tracking (contains API keys)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .env +0 -50
- .gitignore +13 -0
- Dockerfile +1 -4
- chatkit/backend/app/auth.py +78 -0
- chatkit/backend/app/config.py +28 -35
- chatkit/backend/app/database.py +270 -87
- chatkit/backend/app/email_service.py +0 -250
- chatkit/backend/app/file_processor.py +279 -0
- chatkit/backend/app/google_sheets.py +0 -408
- chatkit/backend/app/main.py +243 -295
- chatkit/backend/app/memory_store.py +0 -115
- chatkit/backend/app/oauth.py +0 -251
- chatkit/backend/app/report_generator.py +165 -0
- chatkit/backend/app/server.py +0 -589
- chatkit/backend/app/status_tracker.py +0 -102
- chatkit/backend/pyproject.toml +7 -9
- chatkit/frontend/index.html +1 -2
- chatkit/frontend/package-lock.json +243 -34
- chatkit/frontend/package.json +5 -4
- chatkit/frontend/src/App.tsx +183 -208
- chatkit/frontend/src/TestChat.tsx +0 -12
- chatkit/frontend/src/components/AuthStatus.tsx +0 -212
- chatkit/frontend/src/components/ExamAnalyzer.tsx +0 -237
- chatkit/frontend/src/components/Features.tsx +0 -136
- chatkit/frontend/src/components/Header.tsx +25 -22
- chatkit/frontend/src/components/Hero.tsx +0 -75
- chatkit/frontend/src/components/LoginForm.tsx +142 -0
- chatkit/frontend/src/components/ModelSelector.tsx +69 -0
- chatkit/frontend/src/components/ReasoningPanel.tsx +0 -178
- chatkit/frontend/src/components/SimpleChatPanel.tsx +0 -19
- chatkit/frontend/src/components/StepIndicator.tsx +41 -0
- chatkit/frontend/src/components/WorkflowStatus.tsx +0 -281
- chatkit/frontend/src/components/step1/DataViewer.tsx +112 -0
- chatkit/frontend/src/components/step1/FileUploadPanel.tsx +400 -0
- chatkit/frontend/src/components/step2/ParsedDataSummary.tsx +48 -0
- chatkit/frontend/src/components/step2/PromptEditor.tsx +186 -0
- chatkit/frontend/src/components/step3/ReportViewer.tsx +54 -0
- chatkit/frontend/src/index.css +0 -36
- chatkit/frontend/src/lib/api.ts +88 -0
- chatkit/frontend/src/lib/config.ts +0 -15
- chatkit/frontend/src/types/html2pdf.d.ts +18 -0
- chatkit/frontend/vite.config.ts +2 -11
.env
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
# ExamInsight Environment Variables
|
| 2 |
-
# Copy to .env for local development
|
| 3 |
-
# For HF Spaces, add these as Secrets in the Space settings
|
| 4 |
-
|
| 5 |
-
# =============================================================================
|
| 6 |
-
# REQUIRED
|
| 7 |
-
# =============================================================================
|
| 8 |
-
|
| 9 |
-
# OpenAI API Key (required for ChatKit)
|
| 10 |
-
OPENAI_API_KEY=sk-your-openai-api-key
|
| 11 |
-
|
| 12 |
-
# =============================================================================
|
| 13 |
-
# GOOGLE OAUTH (optional - for private Google Sheets)
|
| 14 |
-
# =============================================================================
|
| 15 |
-
|
| 16 |
-
# Get these from Google Cloud Console > APIs & Services > Credentials
|
| 17 |
-
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
| 18 |
-
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
|
| 19 |
-
|
| 20 |
-
# Redirect URI - update for production
|
| 21 |
-
# Local: http://localhost:8000/auth/callback
|
| 22 |
-
# HF Spaces: https://taboola-cz-examinsight.hf.space/auth/callback
|
| 23 |
-
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback
|
| 24 |
-
|
| 25 |
-
# =============================================================================
|
| 26 |
-
# EMAIL (optional - for sending reports)
|
| 27 |
-
# =============================================================================
|
| 28 |
-
|
| 29 |
-
# Option 1: Gmail SMTP (easier setup)
|
| 30 |
-
GMAIL_USER=your-email@gmail.com
|
| 31 |
-
GMAIL_APP_PASSWORD=your-16-char-app-password
|
| 32 |
-
|
| 33 |
-
# Option 2: SendGrid API
|
| 34 |
-
SENDGRID_API_KEY=SG.your-sendgrid-api-key
|
| 35 |
-
SENDGRID_FROM_EMAIL=examinsight@yourdomain.com
|
| 36 |
-
|
| 37 |
-
# =============================================================================
|
| 38 |
-
# SECURITY
|
| 39 |
-
# =============================================================================
|
| 40 |
-
|
| 41 |
-
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
| 42 |
-
ENCRYPTION_KEY=your-fernet-encryption-key
|
| 43 |
-
|
| 44 |
-
# =============================================================================
|
| 45 |
-
# FRONTEND (for HF Spaces domain key)
|
| 46 |
-
# =============================================================================
|
| 47 |
-
|
| 48 |
-
# Register your HF Space domain at:
|
| 49 |
-
# https://platform.openai.com/settings/organization/security/domain-allowlist
|
| 50 |
-
VITE_CHATKIT_API_DOMAIN_KEY=domain_pk_your-production-key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
|
| 2 |
# Binary files for HF Spaces
|
| 3 |
managed-chatkit/docs/*.jpg
|
|
|
|
| 1 |
+
# Secrets
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Claude Code
|
| 5 |
+
.claude/
|
| 6 |
+
|
| 7 |
+
# Python
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.pyc
|
| 10 |
+
*.db
|
| 11 |
+
|
| 12 |
+
# Node
|
| 13 |
+
node_modules/
|
| 14 |
|
| 15 |
# Binary files for HF Spaces
|
| 16 |
managed-chatkit/docs/*.jpg
|
Dockerfile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
#
|
| 2 |
# Multi-stage build for React frontend + FastAPI backend
|
| 3 |
|
| 4 |
# =============================================================================
|
|
@@ -45,9 +45,6 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|
| 45 |
# Copy backend code
|
| 46 |
COPY --chown=user chatkit/backend/app ./app
|
| 47 |
|
| 48 |
-
# Copy report template
|
| 49 |
-
COPY --chown=user report-template.html ./
|
| 50 |
-
|
| 51 |
# Copy built frontend from Stage 1
|
| 52 |
COPY --from=frontend-builder --chown=user /app/frontend/dist ./static
|
| 53 |
|
|
|
|
| 1 |
+
# ClassLens v2 - Hugging Face Spaces Docker Deployment
|
| 2 |
# Multi-stage build for React frontend + FastAPI backend
|
| 3 |
|
| 4 |
# =============================================================================
|
|
|
|
| 45 |
# Copy backend code
|
| 46 |
COPY --chown=user chatkit/backend/app ./app
|
| 47 |
|
|
|
|
|
|
|
|
|
|
| 48 |
# Copy built frontend from Stage 1
|
| 49 |
COPY --from=frontend-builder --chown=user /app/frontend/dist ./static
|
| 50 |
|
chatkit/backend/app/auth.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Simple email + password JWT authentication for ClassLens."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from passlib.context import CryptContext
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from fastapi import Depends, HTTPException, status
|
| 11 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 12 |
+
|
| 13 |
+
from .config import get_settings
|
| 14 |
+
from .database import get_user_by_email, get_user_by_id, create_user, update_last_login
|
| 15 |
+
|
| 16 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
+
security = HTTPBearer(auto_error=False)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def hash_password(password: str) -> str:
|
| 21 |
+
return pwd_context.hash(password)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 25 |
+
return pwd_context.verify(plain, hashed)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 29 |
+
settings = get_settings()
|
| 30 |
+
to_encode = data.copy()
|
| 31 |
+
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
|
| 32 |
+
to_encode.update({"exp": expire})
|
| 33 |
+
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def get_current_user(
|
| 37 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 38 |
+
) -> dict:
|
| 39 |
+
"""FastAPI dependency: extract and validate JWT, return user dict."""
|
| 40 |
+
if credentials is None:
|
| 41 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
| 42 |
+
|
| 43 |
+
settings = get_settings()
|
| 44 |
+
try:
|
| 45 |
+
payload = jwt.decode(
|
| 46 |
+
credentials.credentials, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]
|
| 47 |
+
)
|
| 48 |
+
user_id: int = payload.get("sub")
|
| 49 |
+
if user_id is None:
|
| 50 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 51 |
+
except JWTError:
|
| 52 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 53 |
+
|
| 54 |
+
user = await get_user_by_id(int(user_id))
|
| 55 |
+
if user is None:
|
| 56 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
| 57 |
+
return user
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def register_user(email: str, password: str) -> dict:
|
| 61 |
+
"""Register a new user. Returns user dict."""
|
| 62 |
+
existing = await get_user_by_email(email)
|
| 63 |
+
if existing:
|
| 64 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 65 |
+
hashed = hash_password(password)
|
| 66 |
+
user_id = await create_user(email, hashed)
|
| 67 |
+
user = await get_user_by_id(user_id)
|
| 68 |
+
return user
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def login_user(email: str, password: str) -> dict:
|
| 72 |
+
"""Validate credentials and return user dict + JWT token."""
|
| 73 |
+
user = await get_user_by_email(email)
|
| 74 |
+
if not user or not verify_password(password, user["password_hash"]):
|
| 75 |
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 76 |
+
await update_last_login(user["id"])
|
| 77 |
+
token = create_access_token({"sub": str(user["id"])})
|
| 78 |
+
return {"token": token, "user": {"id": user["id"], "email": user["email"], "display_name": user["display_name"]}}
|
chatkit/backend/app/config.py
CHANGED
|
@@ -9,35 +9,37 @@ from functools import lru_cache
|
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
from pydantic import BaseModel
|
| 11 |
|
| 12 |
-
# Load .env file
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class Settings(BaseModel):
|
| 18 |
"""Application settings loaded from environment variables."""
|
| 19 |
-
|
| 20 |
-
# OpenAI
|
| 21 |
openai_api_key: str = ""
|
| 22 |
-
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
# SendGrid
|
| 29 |
-
sendgrid_api_key: str = ""
|
| 30 |
-
sendgrid_from_email: str = "classlens@example.com"
|
| 31 |
-
|
| 32 |
# Database
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
# Encryption key for
|
| 36 |
encryption_key: str = ""
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
# Frontend URL for redirects
|
| 39 |
frontend_url: str = "http://localhost:3000"
|
| 40 |
-
|
| 41 |
class Config:
|
| 42 |
env_file = ".env"
|
| 43 |
|
|
@@ -47,21 +49,12 @@ def get_settings() -> Settings:
|
|
| 47 |
"""Get cached settings instance."""
|
| 48 |
return Settings(
|
| 49 |
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
sendgrid_from_email=os.getenv("SENDGRID_FROM_EMAIL", "classlens@example.com"),
|
| 55 |
-
database_url=os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./classlens.db"),
|
| 56 |
encryption_key=os.getenv("ENCRYPTION_KEY", ""),
|
|
|
|
|
|
|
| 57 |
frontend_url=os.getenv("FRONTEND_URL", "http://localhost:3000"),
|
| 58 |
)
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
# Google OAuth scopes required for the application
|
| 62 |
-
# Note: drive.readonly is sensitive scope; spreadsheets.readonly is less restricted
|
| 63 |
-
GOOGLE_SCOPES = [
|
| 64 |
-
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
| 65 |
-
"https://www.googleapis.com/auth/userinfo.email",
|
| 66 |
-
"openid",
|
| 67 |
-
]
|
|
|
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
from pydantic import BaseModel
|
| 11 |
|
| 12 |
+
# Load .env file — try project root first, then chatkit/
|
| 13 |
+
_root = Path(__file__).parent.parent.parent.parent # hf-classlens-deploy/
|
| 14 |
+
_chatkit = Path(__file__).parent.parent.parent # chatkit/
|
| 15 |
+
load_dotenv(_root / ".env")
|
| 16 |
+
load_dotenv(_chatkit / ".env") # overrides root if both exist
|
| 17 |
|
| 18 |
|
| 19 |
class Settings(BaseModel):
|
| 20 |
"""Application settings loaded from environment variables."""
|
| 21 |
+
|
| 22 |
+
# OpenAI (single key for both vision parsing and report generation)
|
| 23 |
openai_api_key: str = ""
|
| 24 |
+
|
| 25 |
+
# JWT Auth
|
| 26 |
+
jwt_secret_key: str = ""
|
| 27 |
+
jwt_algorithm: str = "HS256"
|
| 28 |
+
jwt_expire_minutes: int = 1440 # 24 hours
|
| 29 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
# Database
|
| 31 |
+
database_path: str = "" # auto-resolved if empty
|
| 32 |
+
|
| 33 |
+
# Encryption key for sensitive data
|
| 34 |
encryption_key: str = ""
|
| 35 |
+
|
| 36 |
+
# File upload
|
| 37 |
+
upload_dir: str = "" # auto-resolved if empty
|
| 38 |
+
max_file_size_mb: int = 20
|
| 39 |
+
|
| 40 |
# Frontend URL for redirects
|
| 41 |
frontend_url: str = "http://localhost:3000"
|
| 42 |
+
|
| 43 |
class Config:
|
| 44 |
env_file = ".env"
|
| 45 |
|
|
|
|
| 49 |
"""Get cached settings instance."""
|
| 50 |
return Settings(
|
| 51 |
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
|
| 52 |
+
jwt_secret_key=os.getenv("JWT_SECRET_KEY", "classlens-dev-secret-change-in-prod"),
|
| 53 |
+
jwt_algorithm=os.getenv("JWT_ALGORITHM", "HS256"),
|
| 54 |
+
jwt_expire_minutes=int(os.getenv("JWT_EXPIRE_MINUTES", "1440")),
|
| 55 |
+
database_path=os.getenv("DATABASE_PATH", ""),
|
|
|
|
|
|
|
| 56 |
encryption_key=os.getenv("ENCRYPTION_KEY", ""),
|
| 57 |
+
upload_dir=os.getenv("UPLOAD_DIR", ""),
|
| 58 |
+
max_file_size_mb=int(os.getenv("MAX_FILE_SIZE_MB", "20")),
|
| 59 |
frontend_url=os.getenv("FRONTEND_URL", "http://localhost:3000"),
|
| 60 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/database.py
CHANGED
|
@@ -1,137 +1,320 @@
|
|
| 1 |
-
"""SQLite database for
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import aiosqlite
|
| 6 |
import json
|
| 7 |
-
import base64
|
| 8 |
-
import os
|
| 9 |
from pathlib import Path
|
| 10 |
from datetime import datetime
|
| 11 |
from typing import Optional
|
| 12 |
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
|
| 16 |
DATABASE_PATH = Path(__file__).parent.parent / "classlens.db"
|
| 17 |
|
| 18 |
|
| 19 |
-
def
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
key = Fernet.generate_key().decode()
|
| 25 |
-
print(f"⚠️ No ENCRYPTION_KEY set. Generated temporary key: {key}")
|
| 26 |
-
print("⚠️ Set this in your .env file for persistent token storage.")
|
| 27 |
-
return key.encode() if isinstance(key, str) else key
|
| 28 |
-
|
| 29 |
|
| 30 |
-
def encrypt_token(token_data: dict) -> str:
|
| 31 |
-
"""Encrypt token data for storage."""
|
| 32 |
-
key = get_encryption_key()
|
| 33 |
-
f = Fernet(key)
|
| 34 |
-
json_data = json.dumps(token_data)
|
| 35 |
-
encrypted = f.encrypt(json_data.encode())
|
| 36 |
-
return base64.b64encode(encrypted).decode()
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
async def init_database():
|
| 49 |
-
"""Initialize the database with required tables."""
|
| 50 |
-
async with aiosqlite.connect(DATABASE_PATH) as db:
|
| 51 |
await db.execute("""
|
| 52 |
-
CREATE TABLE IF NOT EXISTS
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
created_at TEXT NOT NULL,
|
| 56 |
-
updated_at TEXT NOT NULL
|
|
|
|
| 57 |
)
|
| 58 |
""")
|
| 59 |
-
|
| 60 |
await db.execute("""
|
| 61 |
-
CREATE TABLE IF NOT EXISTS
|
| 62 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
| 68 |
)
|
| 69 |
""")
|
| 70 |
-
|
| 71 |
await db.commit()
|
| 72 |
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
| 77 |
now = datetime.utcnow().isoformat()
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
ON CONFLICT(teacher_email) DO UPDATE SET
|
| 84 |
-
encrypted_tokens = excluded.encrypted_tokens,
|
| 85 |
-
updated_at = excluded.updated_at
|
| 86 |
-
""", (teacher_email, encrypted, now, now))
|
| 87 |
await db.commit()
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
-
async def
|
| 91 |
-
|
| 92 |
-
async with aiosqlite.connect(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
async with db.execute(
|
| 94 |
-
"SELECT
|
| 95 |
-
(teacher_email,)
|
| 96 |
) as cursor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
row = await cursor.fetchone()
|
| 98 |
-
if row
|
| 99 |
-
return decrypt_token(row[0])
|
| 100 |
-
return None
|
| 101 |
|
| 102 |
|
| 103 |
-
async def
|
| 104 |
-
|
| 105 |
-
|
|
|
|
| 106 |
await db.execute(
|
| 107 |
-
"
|
| 108 |
-
(
|
| 109 |
)
|
| 110 |
await db.commit()
|
| 111 |
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
now = datetime.utcnow().isoformat()
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
""", (teacher_email, exam_title, report_markdown, report_json, now))
|
| 122 |
await db.commit()
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
-
async def
|
| 126 |
-
|
| 127 |
-
async with aiosqlite.connect(
|
| 128 |
db.row_factory = aiosqlite.Row
|
| 129 |
-
async with db.execute(
|
| 130 |
-
SELECT
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
rows = await cursor.fetchall()
|
| 137 |
return [dict(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite database for ClassLens - users, sessions, parsed data, prompts, reports."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import aiosqlite
|
| 6 |
import json
|
|
|
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from datetime import datetime
|
| 9 |
from typing import Optional
|
| 10 |
|
| 11 |
+
from .config import get_settings
|
|
|
|
| 12 |
|
| 13 |
DATABASE_PATH = Path(__file__).parent.parent / "classlens.db"
|
| 14 |
|
| 15 |
|
| 16 |
+
def _get_db_path() -> Path:
|
| 17 |
+
settings = get_settings()
|
| 18 |
+
if settings.database_path:
|
| 19 |
+
return Path(settings.database_path)
|
| 20 |
+
return DATABASE_PATH
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
async def init_database():
|
| 24 |
+
"""Initialize the database with required tables."""
|
| 25 |
+
db_path = _get_db_path()
|
| 26 |
+
async with aiosqlite.connect(db_path) as db:
|
| 27 |
+
await db.execute("""
|
| 28 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 29 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 30 |
+
email TEXT UNIQUE NOT NULL,
|
| 31 |
+
password_hash TEXT NOT NULL,
|
| 32 |
+
display_name TEXT,
|
| 33 |
+
created_at TEXT NOT NULL,
|
| 34 |
+
last_login TEXT
|
| 35 |
+
)
|
| 36 |
+
""")
|
| 37 |
|
| 38 |
+
await db.execute("""
|
| 39 |
+
CREATE TABLE IF NOT EXISTS exam_sessions (
|
| 40 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 41 |
+
user_id INTEGER NOT NULL,
|
| 42 |
+
title TEXT DEFAULT 'Untitled Session',
|
| 43 |
+
status TEXT DEFAULT 'draft',
|
| 44 |
+
created_at TEXT NOT NULL,
|
| 45 |
+
updated_at TEXT NOT NULL,
|
| 46 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 47 |
+
)
|
| 48 |
+
""")
|
| 49 |
|
| 50 |
+
await db.execute("""
|
| 51 |
+
CREATE TABLE IF NOT EXISTS parsed_data (
|
| 52 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 53 |
+
session_id INTEGER NOT NULL,
|
| 54 |
+
data_type TEXT NOT NULL,
|
| 55 |
+
file_name TEXT,
|
| 56 |
+
raw_text TEXT,
|
| 57 |
+
structured_data TEXT NOT NULL,
|
| 58 |
+
created_at TEXT NOT NULL,
|
| 59 |
+
FOREIGN KEY (session_id) REFERENCES exam_sessions(id) ON DELETE CASCADE
|
| 60 |
+
)
|
| 61 |
+
""")
|
| 62 |
|
|
|
|
|
|
|
|
|
|
| 63 |
await db.execute("""
|
| 64 |
+
CREATE TABLE IF NOT EXISTS prompts (
|
| 65 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 66 |
+
user_id INTEGER NOT NULL,
|
| 67 |
+
name TEXT NOT NULL,
|
| 68 |
+
content TEXT NOT NULL,
|
| 69 |
+
is_default BOOLEAN DEFAULT 0,
|
| 70 |
created_at TEXT NOT NULL,
|
| 71 |
+
updated_at TEXT NOT NULL,
|
| 72 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 73 |
)
|
| 74 |
""")
|
| 75 |
+
|
| 76 |
await db.execute("""
|
| 77 |
+
CREATE TABLE IF NOT EXISTS reports (
|
| 78 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 79 |
+
session_id INTEGER NOT NULL,
|
| 80 |
+
prompt_id INTEGER,
|
| 81 |
+
html_content TEXT NOT NULL,
|
| 82 |
+
created_at TEXT NOT NULL,
|
| 83 |
+
FOREIGN KEY (session_id) REFERENCES exam_sessions(id) ON DELETE CASCADE,
|
| 84 |
+
FOREIGN KEY (prompt_id) REFERENCES prompts(id)
|
| 85 |
)
|
| 86 |
""")
|
| 87 |
+
|
| 88 |
await db.commit()
|
| 89 |
|
| 90 |
|
| 91 |
+
# ---- User CRUD ----
|
| 92 |
+
|
| 93 |
+
async def create_user(email: str, password_hash: str, display_name: str = "") -> int:
|
| 94 |
+
db_path = _get_db_path()
|
| 95 |
now = datetime.utcnow().isoformat()
|
| 96 |
+
async with aiosqlite.connect(db_path) as db:
|
| 97 |
+
cursor = await db.execute(
|
| 98 |
+
"INSERT INTO users (email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?)",
|
| 99 |
+
(email, password_hash, display_name or email.split("@")[0], now),
|
| 100 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
await db.commit()
|
| 102 |
+
return cursor.lastrowid
|
| 103 |
|
| 104 |
|
| 105 |
+
async def get_user_by_email(email: str) -> Optional[dict]:
|
| 106 |
+
db_path = _get_db_path()
|
| 107 |
+
async with aiosqlite.connect(db_path) as db:
|
| 108 |
+
db.row_factory = aiosqlite.Row
|
| 109 |
+
async with db.execute("SELECT * FROM users WHERE email = ?", (email,)) as cursor:
|
| 110 |
+
row = await cursor.fetchone()
|
| 111 |
+
return dict(row) if row else None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def get_user_by_id(user_id: int) -> Optional[dict]:
|
| 115 |
+
db_path = _get_db_path()
|
| 116 |
+
async with aiosqlite.connect(db_path) as db:
|
| 117 |
+
db.row_factory = aiosqlite.Row
|
| 118 |
+
async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cursor:
|
| 119 |
+
row = await cursor.fetchone()
|
| 120 |
+
return dict(row) if row else None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
async def update_last_login(user_id: int):
|
| 124 |
+
db_path = _get_db_path()
|
| 125 |
+
now = datetime.utcnow().isoformat()
|
| 126 |
+
async with aiosqlite.connect(db_path) as db:
|
| 127 |
+
await db.execute("UPDATE users SET last_login = ? WHERE id = ?", (now, user_id))
|
| 128 |
+
await db.commit()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---- Session CRUD ----
|
| 132 |
+
|
| 133 |
+
async def create_session(user_id: int, title: str = "Untitled Session") -> int:
|
| 134 |
+
db_path = _get_db_path()
|
| 135 |
+
now = datetime.utcnow().isoformat()
|
| 136 |
+
async with aiosqlite.connect(db_path) as db:
|
| 137 |
+
cursor = await db.execute(
|
| 138 |
+
"INSERT INTO exam_sessions (user_id, title, status, created_at, updated_at) VALUES (?, ?, 'draft', ?, ?)",
|
| 139 |
+
(user_id, title, now, now),
|
| 140 |
+
)
|
| 141 |
+
await db.commit()
|
| 142 |
+
return cursor.lastrowid
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def get_sessions(user_id: int) -> list[dict]:
|
| 146 |
+
db_path = _get_db_path()
|
| 147 |
+
async with aiosqlite.connect(db_path) as db:
|
| 148 |
+
db.row_factory = aiosqlite.Row
|
| 149 |
async with db.execute(
|
| 150 |
+
"SELECT * FROM exam_sessions WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
|
|
|
|
| 151 |
) as cursor:
|
| 152 |
+
rows = await cursor.fetchall()
|
| 153 |
+
return [dict(row) for row in rows]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def get_session(session_id: int) -> Optional[dict]:
|
| 157 |
+
db_path = _get_db_path()
|
| 158 |
+
async with aiosqlite.connect(db_path) as db:
|
| 159 |
+
db.row_factory = aiosqlite.Row
|
| 160 |
+
async with db.execute("SELECT * FROM exam_sessions WHERE id = ?", (session_id,)) as cursor:
|
| 161 |
row = await cursor.fetchone()
|
| 162 |
+
return dict(row) if row else None
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
+
async def update_session_status(session_id: int, status: str):
|
| 166 |
+
db_path = _get_db_path()
|
| 167 |
+
now = datetime.utcnow().isoformat()
|
| 168 |
+
async with aiosqlite.connect(db_path) as db:
|
| 169 |
await db.execute(
|
| 170 |
+
"UPDATE exam_sessions SET status = ?, updated_at = ? WHERE id = ?",
|
| 171 |
+
(status, now, session_id),
|
| 172 |
)
|
| 173 |
await db.commit()
|
| 174 |
|
| 175 |
|
| 176 |
+
# ---- Parsed Data CRUD ----
|
| 177 |
+
|
| 178 |
+
async def save_parsed_data(
|
| 179 |
+
session_id: int, data_type: str, file_name: str, raw_text: str, structured_data: dict
|
| 180 |
+
) -> int:
|
| 181 |
+
db_path = _get_db_path()
|
| 182 |
now = datetime.utcnow().isoformat()
|
| 183 |
+
async with aiosqlite.connect(db_path) as db:
|
| 184 |
+
cursor = await db.execute(
|
| 185 |
+
"INSERT INTO parsed_data (session_id, data_type, file_name, raw_text, structured_data, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
| 186 |
+
(session_id, data_type, file_name, raw_text, json.dumps(structured_data, ensure_ascii=False), now),
|
| 187 |
+
)
|
|
|
|
| 188 |
await db.commit()
|
| 189 |
+
return cursor.lastrowid
|
| 190 |
|
| 191 |
|
| 192 |
+
async def get_parsed_data(session_id: int) -> list[dict]:
|
| 193 |
+
db_path = _get_db_path()
|
| 194 |
+
async with aiosqlite.connect(db_path) as db:
|
| 195 |
db.row_factory = aiosqlite.Row
|
| 196 |
+
async with db.execute(
|
| 197 |
+
"SELECT * FROM parsed_data WHERE session_id = ? ORDER BY data_type, created_at",
|
| 198 |
+
(session_id,),
|
| 199 |
+
) as cursor:
|
| 200 |
+
rows = await cursor.fetchall()
|
| 201 |
+
result = []
|
| 202 |
+
for row in rows:
|
| 203 |
+
d = dict(row)
|
| 204 |
+
d["structured_data"] = json.loads(d["structured_data"])
|
| 205 |
+
result.append(d)
|
| 206 |
+
return result
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
async def delete_parsed_data(session_id: int, data_type: Optional[str] = None):
|
| 210 |
+
db_path = _get_db_path()
|
| 211 |
+
async with aiosqlite.connect(db_path) as db:
|
| 212 |
+
if data_type:
|
| 213 |
+
await db.execute(
|
| 214 |
+
"DELETE FROM parsed_data WHERE session_id = ? AND data_type = ?",
|
| 215 |
+
(session_id, data_type),
|
| 216 |
+
)
|
| 217 |
+
else:
|
| 218 |
+
await db.execute("DELETE FROM parsed_data WHERE session_id = ?", (session_id,))
|
| 219 |
+
await db.commit()
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ---- Prompt CRUD ----
|
| 223 |
+
|
| 224 |
+
async def save_prompt(user_id: int, name: str, content: str, is_default: bool = False) -> int:
|
| 225 |
+
db_path = _get_db_path()
|
| 226 |
+
now = datetime.utcnow().isoformat()
|
| 227 |
+
async with aiosqlite.connect(db_path) as db:
|
| 228 |
+
cursor = await db.execute(
|
| 229 |
+
"INSERT INTO prompts (user_id, name, content, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
| 230 |
+
(user_id, name, content, is_default, now, now),
|
| 231 |
+
)
|
| 232 |
+
await db.commit()
|
| 233 |
+
return cursor.lastrowid
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def get_prompts(user_id: int) -> list[dict]:
|
| 237 |
+
"""Get user's own prompts plus all default prompts."""
|
| 238 |
+
db_path = _get_db_path()
|
| 239 |
+
async with aiosqlite.connect(db_path) as db:
|
| 240 |
+
db.row_factory = aiosqlite.Row
|
| 241 |
+
async with db.execute(
|
| 242 |
+
"SELECT * FROM prompts WHERE user_id = ? OR is_default = 1 ORDER BY is_default DESC, updated_at DESC",
|
| 243 |
+
(user_id,),
|
| 244 |
+
) as cursor:
|
| 245 |
+
rows = await cursor.fetchall()
|
| 246 |
+
return [dict(row) for row in rows]
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def get_all_prompts() -> list[dict]:
|
| 250 |
+
"""Get all prompts from all users (for the 'read others' dropdown)."""
|
| 251 |
+
db_path = _get_db_path()
|
| 252 |
+
async with aiosqlite.connect(db_path) as db:
|
| 253 |
+
db.row_factory = aiosqlite.Row
|
| 254 |
+
async with db.execute(
|
| 255 |
+
"SELECT p.*, u.email as author_email FROM prompts p JOIN users u ON p.user_id = u.id ORDER BY p.updated_at DESC"
|
| 256 |
+
) as cursor:
|
| 257 |
rows = await cursor.fetchall()
|
| 258 |
return [dict(row) for row in rows]
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
async def get_prompt(prompt_id: int) -> Optional[dict]:
|
| 262 |
+
db_path = _get_db_path()
|
| 263 |
+
async with aiosqlite.connect(db_path) as db:
|
| 264 |
+
db.row_factory = aiosqlite.Row
|
| 265 |
+
async with db.execute("SELECT * FROM prompts WHERE id = ?", (prompt_id,)) as cursor:
|
| 266 |
+
row = await cursor.fetchone()
|
| 267 |
+
return dict(row) if row else None
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
async def update_prompt(prompt_id: int, name: str, content: str):
|
| 271 |
+
db_path = _get_db_path()
|
| 272 |
+
now = datetime.utcnow().isoformat()
|
| 273 |
+
async with aiosqlite.connect(db_path) as db:
|
| 274 |
+
await db.execute(
|
| 275 |
+
"UPDATE prompts SET name = ?, content = ?, updated_at = ? WHERE id = ?",
|
| 276 |
+
(name, content, now, prompt_id),
|
| 277 |
+
)
|
| 278 |
+
await db.commit()
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
async def delete_prompt(prompt_id: int):
|
| 282 |
+
db_path = _get_db_path()
|
| 283 |
+
async with aiosqlite.connect(db_path) as db:
|
| 284 |
+
await db.execute("DELETE FROM prompts WHERE id = ?", (prompt_id,))
|
| 285 |
+
await db.commit()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ---- Report CRUD ----
|
| 289 |
+
|
| 290 |
+
async def save_report(session_id: int, prompt_id: Optional[int], html_content: str) -> int:
|
| 291 |
+
db_path = _get_db_path()
|
| 292 |
+
now = datetime.utcnow().isoformat()
|
| 293 |
+
async with aiosqlite.connect(db_path) as db:
|
| 294 |
+
cursor = await db.execute(
|
| 295 |
+
"INSERT INTO reports (session_id, prompt_id, html_content, created_at) VALUES (?, ?, ?, ?)",
|
| 296 |
+
(session_id, prompt_id, html_content, now),
|
| 297 |
+
)
|
| 298 |
+
await db.commit()
|
| 299 |
+
return cursor.lastrowid
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
async def get_reports(session_id: int) -> list[dict]:
|
| 303 |
+
db_path = _get_db_path()
|
| 304 |
+
async with aiosqlite.connect(db_path) as db:
|
| 305 |
+
db.row_factory = aiosqlite.Row
|
| 306 |
+
async with db.execute(
|
| 307 |
+
"SELECT * FROM reports WHERE session_id = ? ORDER BY created_at DESC",
|
| 308 |
+
(session_id,),
|
| 309 |
+
) as cursor:
|
| 310 |
+
rows = await cursor.fetchall()
|
| 311 |
+
return [dict(row) for row in rows]
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
async def get_report(report_id: int) -> Optional[dict]:
|
| 315 |
+
db_path = _get_db_path()
|
| 316 |
+
async with aiosqlite.connect(db_path) as db:
|
| 317 |
+
db.row_factory = aiosqlite.Row
|
| 318 |
+
async with db.execute("SELECT * FROM reports WHERE id = ?", (report_id,)) as cursor:
|
| 319 |
+
row = await cursor.fetchone()
|
| 320 |
+
return dict(row) if row else None
|
chatkit/backend/app/email_service.py
DELETED
|
@@ -1,250 +0,0 @@
|
|
| 1 |
-
"""Email service for sending exam reports to teachers."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import os
|
| 6 |
-
import smtplib
|
| 7 |
-
import ssl
|
| 8 |
-
from email.mime.text import MIMEText
|
| 9 |
-
from email.mime.multipart import MIMEMultipart
|
| 10 |
-
from typing import Optional
|
| 11 |
-
|
| 12 |
-
import markdown
|
| 13 |
-
|
| 14 |
-
from .config import get_settings
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def markdown_to_html(md_content: str) -> str:
|
| 18 |
-
"""Convert markdown to styled HTML for email."""
|
| 19 |
-
# Convert markdown to HTML
|
| 20 |
-
html_body = markdown.markdown(
|
| 21 |
-
md_content,
|
| 22 |
-
extensions=['tables', 'fenced_code', 'nl2br']
|
| 23 |
-
)
|
| 24 |
-
|
| 25 |
-
# Wrap in email template
|
| 26 |
-
return f"""
|
| 27 |
-
<!DOCTYPE html>
|
| 28 |
-
<html>
|
| 29 |
-
<head>
|
| 30 |
-
<meta charset="utf-8">
|
| 31 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 32 |
-
<style>
|
| 33 |
-
body {{
|
| 34 |
-
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 35 |
-
line-height: 1.6;
|
| 36 |
-
color: #333;
|
| 37 |
-
max-width: 800px;
|
| 38 |
-
margin: 0 auto;
|
| 39 |
-
padding: 20px;
|
| 40 |
-
background-color: #f5f5f5;
|
| 41 |
-
}}
|
| 42 |
-
.container {{
|
| 43 |
-
background-color: white;
|
| 44 |
-
padding: 30px;
|
| 45 |
-
border-radius: 10px;
|
| 46 |
-
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 47 |
-
}}
|
| 48 |
-
h1 {{
|
| 49 |
-
color: #2563eb;
|
| 50 |
-
border-bottom: 2px solid #2563eb;
|
| 51 |
-
padding-bottom: 10px;
|
| 52 |
-
}}
|
| 53 |
-
h2 {{
|
| 54 |
-
color: #1e40af;
|
| 55 |
-
margin-top: 30px;
|
| 56 |
-
}}
|
| 57 |
-
h3 {{
|
| 58 |
-
color: #3b82f6;
|
| 59 |
-
}}
|
| 60 |
-
table {{
|
| 61 |
-
width: 100%;
|
| 62 |
-
border-collapse: collapse;
|
| 63 |
-
margin: 20px 0;
|
| 64 |
-
}}
|
| 65 |
-
th, td {{
|
| 66 |
-
border: 1px solid #ddd;
|
| 67 |
-
padding: 12px;
|
| 68 |
-
text-align: left;
|
| 69 |
-
}}
|
| 70 |
-
th {{
|
| 71 |
-
background-color: #2563eb;
|
| 72 |
-
color: white;
|
| 73 |
-
}}
|
| 74 |
-
tr:nth-child(even) {{
|
| 75 |
-
background-color: #f8fafc;
|
| 76 |
-
}}
|
| 77 |
-
code {{
|
| 78 |
-
background-color: #f1f5f9;
|
| 79 |
-
padding: 2px 6px;
|
| 80 |
-
border-radius: 4px;
|
| 81 |
-
font-family: monospace;
|
| 82 |
-
}}
|
| 83 |
-
.footer {{
|
| 84 |
-
margin-top: 40px;
|
| 85 |
-
padding-top: 20px;
|
| 86 |
-
border-top: 1px solid #ddd;
|
| 87 |
-
text-align: center;
|
| 88 |
-
color: #666;
|
| 89 |
-
font-size: 0.9em;
|
| 90 |
-
}}
|
| 91 |
-
</style>
|
| 92 |
-
</head>
|
| 93 |
-
<body>
|
| 94 |
-
<div class="container">
|
| 95 |
-
{html_body}
|
| 96 |
-
<div class="footer">
|
| 97 |
-
<p>Generated by ClassLens • AI-Powered Exam Analysis for Teachers</p>
|
| 98 |
-
</div>
|
| 99 |
-
</div>
|
| 100 |
-
</body>
|
| 101 |
-
</html>
|
| 102 |
-
"""
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
async def send_email_via_gmail(
|
| 106 |
-
to_email: str,
|
| 107 |
-
subject: str,
|
| 108 |
-
body_markdown: str,
|
| 109 |
-
gmail_user: str,
|
| 110 |
-
gmail_app_password: str
|
| 111 |
-
) -> dict:
|
| 112 |
-
"""
|
| 113 |
-
Send email using Gmail SMTP.
|
| 114 |
-
|
| 115 |
-
Requires:
|
| 116 |
-
- Gmail account
|
| 117 |
-
- App Password (not your regular password)
|
| 118 |
-
|
| 119 |
-
To get an App Password:
|
| 120 |
-
1. Enable 2-Step Verification on your Google account
|
| 121 |
-
2. Go to https://myaccount.google.com/apppasswords
|
| 122 |
-
3. Generate an app password for "Mail"
|
| 123 |
-
"""
|
| 124 |
-
try:
|
| 125 |
-
html_content = markdown_to_html(body_markdown)
|
| 126 |
-
|
| 127 |
-
# Create message
|
| 128 |
-
message = MIMEMultipart("alternative")
|
| 129 |
-
message["Subject"] = subject
|
| 130 |
-
message["From"] = gmail_user
|
| 131 |
-
message["To"] = to_email
|
| 132 |
-
|
| 133 |
-
# Add plain text and HTML versions
|
| 134 |
-
part1 = MIMEText(body_markdown, "plain")
|
| 135 |
-
part2 = MIMEText(html_content, "html")
|
| 136 |
-
message.attach(part1)
|
| 137 |
-
message.attach(part2)
|
| 138 |
-
|
| 139 |
-
# Create secure connection and send
|
| 140 |
-
context = ssl.create_default_context()
|
| 141 |
-
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as server:
|
| 142 |
-
server.login(gmail_user, gmail_app_password)
|
| 143 |
-
server.sendmail(gmail_user, to_email, message.as_string())
|
| 144 |
-
|
| 145 |
-
return {"status": "ok", "message": "Email sent via Gmail"}
|
| 146 |
-
|
| 147 |
-
except smtplib.SMTPAuthenticationError:
|
| 148 |
-
return {
|
| 149 |
-
"status": "error",
|
| 150 |
-
"message": "Gmail authentication failed. Check your email and app password."
|
| 151 |
-
}
|
| 152 |
-
except Exception as e:
|
| 153 |
-
return {
|
| 154 |
-
"status": "error",
|
| 155 |
-
"message": f"Failed to send email: {str(e)}"
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
async def send_email_via_sendgrid(
|
| 160 |
-
to_email: str,
|
| 161 |
-
subject: str,
|
| 162 |
-
body_markdown: str
|
| 163 |
-
) -> dict:
|
| 164 |
-
"""Send email using SendGrid API."""
|
| 165 |
-
settings = get_settings()
|
| 166 |
-
|
| 167 |
-
try:
|
| 168 |
-
from sendgrid import SendGridAPIClient
|
| 169 |
-
from sendgrid.helpers.mail import Mail, Email, To, Content, HtmlContent
|
| 170 |
-
|
| 171 |
-
html_content = markdown_to_html(body_markdown)
|
| 172 |
-
|
| 173 |
-
message = Mail(
|
| 174 |
-
from_email=Email(settings.sendgrid_from_email, "ClassLens"),
|
| 175 |
-
to_emails=To(to_email),
|
| 176 |
-
subject=subject,
|
| 177 |
-
html_content=HtmlContent(html_content)
|
| 178 |
-
)
|
| 179 |
-
message.add_content(Content("text/plain", body_markdown))
|
| 180 |
-
|
| 181 |
-
sg = SendGridAPIClient(settings.sendgrid_api_key)
|
| 182 |
-
response = sg.send(message)
|
| 183 |
-
|
| 184 |
-
if response.status_code in (200, 201, 202):
|
| 185 |
-
return {"status": "ok", "message": "Email sent via SendGrid"}
|
| 186 |
-
else:
|
| 187 |
-
return {
|
| 188 |
-
"status": "error",
|
| 189 |
-
"message": f"SendGrid returned status {response.status_code}"
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
except Exception as e:
|
| 193 |
-
return {
|
| 194 |
-
"status": "error",
|
| 195 |
-
"message": str(e)
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
async def send_email_report(
|
| 200 |
-
email: str,
|
| 201 |
-
subject: str,
|
| 202 |
-
body_markdown: str
|
| 203 |
-
) -> dict:
|
| 204 |
-
"""
|
| 205 |
-
Send an email report to a teacher.
|
| 206 |
-
|
| 207 |
-
Tries in order:
|
| 208 |
-
1. Gmail SMTP (if configured)
|
| 209 |
-
2. SendGrid (if configured)
|
| 210 |
-
3. Logs to console (fallback)
|
| 211 |
-
|
| 212 |
-
Returns:
|
| 213 |
-
{"status": "ok"} on success
|
| 214 |
-
{"status": "error", "message": str} on failure
|
| 215 |
-
"""
|
| 216 |
-
# Try Gmail SMTP first
|
| 217 |
-
gmail_user = os.getenv("GMAIL_USER", "")
|
| 218 |
-
gmail_app_password = os.getenv("GMAIL_APP_PASSWORD", "")
|
| 219 |
-
|
| 220 |
-
if gmail_user and gmail_app_password:
|
| 221 |
-
result = await send_email_via_gmail(email, subject, body_markdown, gmail_user, gmail_app_password)
|
| 222 |
-
if result["status"] == "ok":
|
| 223 |
-
print(f"📧 Email sent to {email} via Gmail")
|
| 224 |
-
return result
|
| 225 |
-
else:
|
| 226 |
-
print(f"⚠️ Gmail failed: {result['message']}, trying fallback...")
|
| 227 |
-
|
| 228 |
-
# Try SendGrid
|
| 229 |
-
settings = get_settings()
|
| 230 |
-
if settings.sendgrid_api_key:
|
| 231 |
-
result = await send_email_via_sendgrid(email, subject, body_markdown)
|
| 232 |
-
if result["status"] == "ok":
|
| 233 |
-
print(f"📧 Email sent to {email} via SendGrid")
|
| 234 |
-
return result
|
| 235 |
-
else:
|
| 236 |
-
print(f"⚠️ SendGrid failed: {result['message']}")
|
| 237 |
-
|
| 238 |
-
# Fallback: print to console
|
| 239 |
-
print(f"\n{'='*60}")
|
| 240 |
-
print(f"📧 EMAIL (not sent - no email service configured)")
|
| 241 |
-
print(f"To: {email}")
|
| 242 |
-
print(f"Subject: {subject}")
|
| 243 |
-
print(f"{'='*60}")
|
| 244 |
-
print(body_markdown[:500] + "..." if len(body_markdown) > 500 else body_markdown)
|
| 245 |
-
print(f"{'='*60}\n")
|
| 246 |
-
|
| 247 |
-
return {
|
| 248 |
-
"status": "ok",
|
| 249 |
-
"message": "Email logged to console (configure GMAIL_USER/GMAIL_APP_PASSWORD or SENDGRID_API_KEY to send real emails)"
|
| 250 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/file_processor.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File processing: upload files and extract structured Q&A via GPT-4 Vision / GPT-4o."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import json
|
| 7 |
+
import mimetypes
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import fitz # pymupdf
|
| 12 |
+
from openai import AsyncOpenAI
|
| 13 |
+
from fastapi import UploadFile
|
| 14 |
+
|
| 15 |
+
from .config import get_settings
|
| 16 |
+
from .database import save_parsed_data, delete_parsed_data
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# Extensions that should be read as text, not sent as images
|
| 20 |
+
TEXT_EXTENSIONS = {".txt", ".csv", ".tsv", ".json", ".xml", ".md", ".html", ".htm", ".log"}
|
| 21 |
+
|
| 22 |
+
# Image extensions with their MIME types
|
| 23 |
+
IMAGE_MIME = {
|
| 24 |
+
".png": "image/png",
|
| 25 |
+
".jpg": "image/jpeg",
|
| 26 |
+
".jpeg": "image/jpeg",
|
| 27 |
+
".gif": "image/gif",
|
| 28 |
+
".bmp": "image/bmp",
|
| 29 |
+
".webp": "image/webp",
|
| 30 |
+
".tiff": "image/tiff",
|
| 31 |
+
".tif": "image/tiff",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
NO_FABRICATION_RULE = """
|
| 35 |
+
CRITICAL: Only extract data that is ACTUALLY present in the provided content.
|
| 36 |
+
Do NOT invent, fabricate, or hallucinate any data.
|
| 37 |
+
If the content is empty, unreadable, or does not contain the expected data, return an empty result like {"questions": []} or {"students": []} or {"answers": []}.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
IMAGE_DESCRIPTION_RULE = """
|
| 41 |
+
IMPORTANT - Images, Diagrams, Charts, and Figures:
|
| 42 |
+
- If a question contains or references an image, diagram, chart, graph, map, table, or any visual element, you MUST describe it in detail as part of the question text.
|
| 43 |
+
- Use the format: [圖片描述: ...detailed description...] inserted where the image appears in the question.
|
| 44 |
+
- The description must be detailed enough that someone who CANNOT see the image can still fully understand and answer the question.
|
| 45 |
+
- Include all relevant data points, labels, axes, values, shapes, positions, colors, relationships, and any text visible in the image.
|
| 46 |
+
- For charts/graphs: describe the type, axes labels, data values, trends, and all visible data points.
|
| 47 |
+
- For diagrams: describe all components, connections, labels, measurements, and spatial relationships.
|
| 48 |
+
- For maps: describe locations, boundaries, labels, and any marked features.
|
| 49 |
+
- For tables embedded as images: transcribe the full table content.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
EXTRACTION_PROMPTS = {
|
| 53 |
+
"questions": """You are an exam data extraction expert. Extract all exam questions from the provided content.
|
| 54 |
+
|
| 55 |
+
Return a JSON object with this structure:
|
| 56 |
+
{
|
| 57 |
+
"questions": [
|
| 58 |
+
{
|
| 59 |
+
"number": 1,
|
| 60 |
+
"text": "the full question text (including [圖片描述: ...] for any images/diagrams)",
|
| 61 |
+
"type": "multiple_choice" or "short_answer" or "essay" or "true_false",
|
| 62 |
+
"options": ["A) ...", "B) ...", "C) ...", "D) ..."], // null if not multiple choice
|
| 63 |
+
"points": 10 // point value if visible, null otherwise
|
| 64 |
+
}
|
| 65 |
+
]
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
Rules:
|
| 69 |
+
- Extract EVERY question you can see
|
| 70 |
+
- Preserve the original language of the questions
|
| 71 |
+
- If options are present, include them exactly as written
|
| 72 |
+
- If point values are shown, include them
|
| 73 |
+
""" + IMAGE_DESCRIPTION_RULE + NO_FABRICATION_RULE + "- Return valid JSON only",
|
| 74 |
+
|
| 75 |
+
"student_answers": """You are an exam data extraction expert. Extract all student answers from the provided content.
|
| 76 |
+
|
| 77 |
+
Return a JSON object with this structure:
|
| 78 |
+
{
|
| 79 |
+
"students": [
|
| 80 |
+
{
|
| 81 |
+
"name": "student name or ID",
|
| 82 |
+
"id": "student ID if visible",
|
| 83 |
+
"answers": [
|
| 84 |
+
{
|
| 85 |
+
"question_number": 1,
|
| 86 |
+
"answer": "the student's answer text"
|
| 87 |
+
}
|
| 88 |
+
]
|
| 89 |
+
}
|
| 90 |
+
]
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
Rules:
|
| 94 |
+
- Extract answers for EVERY student visible
|
| 95 |
+
- Preserve exact answer text
|
| 96 |
+
- If a student left a question blank, set answer to null
|
| 97 |
+
- Use the student's name or ID as shown in the document
|
| 98 |
+
- If a student's answer includes a drawing or diagram, describe it as text: [圖片描述: ...]
|
| 99 |
+
""" + IMAGE_DESCRIPTION_RULE + NO_FABRICATION_RULE + "- Return valid JSON only",
|
| 100 |
+
|
| 101 |
+
"teacher_answers": """You are an exam data extraction expert. Extract the correct/model answers (answer key) from the provided content.
|
| 102 |
+
|
| 103 |
+
Return a JSON object with this structure:
|
| 104 |
+
{
|
| 105 |
+
"answers": [
|
| 106 |
+
{
|
| 107 |
+
"question_number": 1,
|
| 108 |
+
"correct_answer": "the correct answer",
|
| 109 |
+
"explanation": "explanation if provided, otherwise null"
|
| 110 |
+
}
|
| 111 |
+
]
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
Rules:
|
| 115 |
+
- Extract the correct answer for EVERY question
|
| 116 |
+
- Include explanations if they are provided
|
| 117 |
+
- Preserve exact answer text
|
| 118 |
+
- If the answer references or includes an image/diagram, describe it as text: [圖片描述: ...]
|
| 119 |
+
""" + IMAGE_DESCRIPTION_RULE + NO_FABRICATION_RULE + "- Return valid JSON only",
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def pdf_to_images(pdf_bytes: bytes) -> list[bytes]:
|
| 124 |
+
"""Convert PDF bytes to a list of PNG image bytes, one per page."""
|
| 125 |
+
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 126 |
+
images = []
|
| 127 |
+
for page in doc:
|
| 128 |
+
# Render at 2x resolution for better OCR quality
|
| 129 |
+
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
| 130 |
+
images.append(pix.tobytes("png"))
|
| 131 |
+
doc.close()
|
| 132 |
+
return images
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def try_read_as_text(file_bytes: bytes, filename: str) -> Optional[str]:
|
| 136 |
+
"""Try to decode file bytes as text. Returns None if not text."""
|
| 137 |
+
ext = Path(filename).suffix.lower()
|
| 138 |
+
if ext in TEXT_EXTENSIONS:
|
| 139 |
+
for encoding in ("utf-8", "utf-8-sig", "big5", "gb2312", "shift_jis", "latin-1"):
|
| 140 |
+
try:
|
| 141 |
+
return file_bytes.decode(encoding)
|
| 142 |
+
except (UnicodeDecodeError, ValueError):
|
| 143 |
+
continue
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def get_image_mime(filename: str) -> Optional[str]:
|
| 148 |
+
"""Get MIME type for image files. Returns None if not a known image type."""
|
| 149 |
+
ext = Path(filename).suffix.lower()
|
| 150 |
+
return IMAGE_MIME.get(ext)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
async def process_uploaded_files(
|
| 154 |
+
files: list[UploadFile],
|
| 155 |
+
data_type: str,
|
| 156 |
+
session_id: int,
|
| 157 |
+
description: str = "",
|
| 158 |
+
model: str = "gpt-5.4",
|
| 159 |
+
) -> dict:
|
| 160 |
+
"""
|
| 161 |
+
Process uploaded files: convert to images/text, then extract data via GPT-4o.
|
| 162 |
+
Returns the structured data extracted.
|
| 163 |
+
"""
|
| 164 |
+
image_parts: list[dict] = [] # image content parts for GPT
|
| 165 |
+
text_parts: list[str] = [] # text content from text files
|
| 166 |
+
source_files: list[str] = []
|
| 167 |
+
|
| 168 |
+
for file in files:
|
| 169 |
+
file_bytes = await file.read()
|
| 170 |
+
filename = file.filename or "unknown"
|
| 171 |
+
|
| 172 |
+
if not file_bytes:
|
| 173 |
+
raise ValueError(f"File '{filename}' is empty (0 bytes). Please upload a valid file.")
|
| 174 |
+
|
| 175 |
+
ext = Path(filename).suffix.lower()
|
| 176 |
+
source_files.append(filename)
|
| 177 |
+
|
| 178 |
+
if ext == ".pdf":
|
| 179 |
+
page_images = pdf_to_images(file_bytes)
|
| 180 |
+
for i, img in enumerate(page_images):
|
| 181 |
+
b64 = base64.b64encode(img).decode()
|
| 182 |
+
image_parts.append({
|
| 183 |
+
"type": "image_url",
|
| 184 |
+
"image_url": {"url": f"data:image/png;base64,{b64}", "detail": "high"},
|
| 185 |
+
})
|
| 186 |
+
else:
|
| 187 |
+
# Try to read as text first
|
| 188 |
+
text_content = try_read_as_text(file_bytes, filename)
|
| 189 |
+
if text_content is not None:
|
| 190 |
+
text_parts.append(f"--- Content from {filename} ---\n{text_content}")
|
| 191 |
+
else:
|
| 192 |
+
# Try as image with correct MIME type
|
| 193 |
+
mime = get_image_mime(filename) or "image/png"
|
| 194 |
+
b64 = base64.b64encode(file_bytes).decode()
|
| 195 |
+
image_parts.append({
|
| 196 |
+
"type": "image_url",
|
| 197 |
+
"image_url": {"url": f"data:{mime};base64,{b64}", "detail": "high"},
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
if not image_parts and not text_parts:
|
| 201 |
+
raise ValueError("No valid content found in uploaded files")
|
| 202 |
+
|
| 203 |
+
# Extract data using selected model
|
| 204 |
+
structured = await extract_data(
|
| 205 |
+
image_parts=image_parts,
|
| 206 |
+
text_parts=text_parts,
|
| 207 |
+
data_type=data_type,
|
| 208 |
+
description=description,
|
| 209 |
+
model=model,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
# Clear old data of this type for the session, then save new
|
| 213 |
+
await delete_parsed_data(session_id, data_type)
|
| 214 |
+
|
| 215 |
+
raw_text = json.dumps({"source_files": source_files}, ensure_ascii=False)
|
| 216 |
+
await save_parsed_data(session_id, data_type, files[0].filename or "upload", raw_text, structured)
|
| 217 |
+
|
| 218 |
+
return structured
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
async def extract_data(
|
| 222 |
+
image_parts: list[dict],
|
| 223 |
+
text_parts: list[str],
|
| 224 |
+
data_type: str,
|
| 225 |
+
description: str = "",
|
| 226 |
+
model: str = "gpt-5.4",
|
| 227 |
+
) -> dict:
|
| 228 |
+
"""Call GPT-4o to extract structured data from images and/or text."""
|
| 229 |
+
settings = get_settings()
|
| 230 |
+
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 231 |
+
|
| 232 |
+
system_prompt = EXTRACTION_PROMPTS.get(data_type)
|
| 233 |
+
if not system_prompt:
|
| 234 |
+
raise ValueError(f"Unknown data_type: {data_type}")
|
| 235 |
+
|
| 236 |
+
# Build user message content
|
| 237 |
+
user_text = f"Please extract the {data_type.replace('_', ' ')} from the provided content."
|
| 238 |
+
if description.strip():
|
| 239 |
+
user_text = f"Context about this data: {description.strip()}\n\n{user_text}"
|
| 240 |
+
|
| 241 |
+
# If we have text content, include it
|
| 242 |
+
if text_parts:
|
| 243 |
+
combined_text = "\n\n".join(text_parts)
|
| 244 |
+
user_text += f"\n\n--- FILE CONTENT ---\n{combined_text}"
|
| 245 |
+
|
| 246 |
+
content_parts: list[dict] = [{"type": "text", "text": user_text}]
|
| 247 |
+
content_parts.extend(image_parts)
|
| 248 |
+
|
| 249 |
+
messages = [
|
| 250 |
+
{"role": "system", "content": system_prompt},
|
| 251 |
+
{"role": "user", "content": content_parts},
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
# Build kwargs — some models don't support all parameters
|
| 255 |
+
kwargs: dict = {
|
| 256 |
+
"model": model,
|
| 257 |
+
"messages": messages,
|
| 258 |
+
"max_completion_tokens": 16384,
|
| 259 |
+
}
|
| 260 |
+
# json_object response_format may not work on all models; try with it first
|
| 261 |
+
try:
|
| 262 |
+
response = await client.chat.completions.create(
|
| 263 |
+
**kwargs,
|
| 264 |
+
response_format={"type": "json_object"},
|
| 265 |
+
)
|
| 266 |
+
except Exception:
|
| 267 |
+
# Fallback: no response_format, rely on prompt to return JSON
|
| 268 |
+
response = await client.chat.completions.create(**kwargs)
|
| 269 |
+
|
| 270 |
+
choice = response.choices[0]
|
| 271 |
+
content = choice.message.content
|
| 272 |
+
if not content:
|
| 273 |
+
reason = getattr(choice, "finish_reason", "unknown")
|
| 274 |
+
refusal = getattr(choice.message, "refusal", None)
|
| 275 |
+
detail = f"finish_reason={reason}"
|
| 276 |
+
if refusal:
|
| 277 |
+
detail += f", refusal={refusal}"
|
| 278 |
+
raise ValueError(f"Model returned empty content ({detail}). Try a different model.")
|
| 279 |
+
return json.loads(content)
|
chatkit/backend/app/google_sheets.py
DELETED
|
@@ -1,408 +0,0 @@
|
|
| 1 |
-
"""Google Sheets API service for fetching form responses."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import re
|
| 6 |
-
import csv
|
| 7 |
-
from io import StringIO
|
| 8 |
-
from typing import Optional
|
| 9 |
-
from datetime import datetime
|
| 10 |
-
|
| 11 |
-
import httpx
|
| 12 |
-
|
| 13 |
-
from google.oauth2.credentials import Credentials
|
| 14 |
-
from google.auth.transport.requests import Request
|
| 15 |
-
from googleapiclient.discovery import build
|
| 16 |
-
from googleapiclient.errors import HttpError
|
| 17 |
-
|
| 18 |
-
from .database import get_oauth_tokens, save_oauth_tokens
|
| 19 |
-
from .config import get_settings, GOOGLE_SCOPES
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def extract_sheet_id(url: str) -> Optional[str]:
|
| 23 |
-
"""Extract the spreadsheet ID from a Google Sheets or Forms URL."""
|
| 24 |
-
# Google Sheets URL pattern
|
| 25 |
-
sheets_pattern = r'/spreadsheets/d/([a-zA-Z0-9-_]+)'
|
| 26 |
-
sheets_match = re.search(sheets_pattern, url)
|
| 27 |
-
if sheets_match:
|
| 28 |
-
return sheets_match.group(1)
|
| 29 |
-
|
| 30 |
-
# Google Forms URL pattern - need to find linked response sheet
|
| 31 |
-
forms_pattern = r'/forms/d/e?/?([a-zA-Z0-9-_]+)'
|
| 32 |
-
forms_match = re.search(forms_pattern, url)
|
| 33 |
-
if forms_match:
|
| 34 |
-
# For forms, we need to handle this differently
|
| 35 |
-
# The user should provide the response sheet URL directly
|
| 36 |
-
return None
|
| 37 |
-
|
| 38 |
-
return None
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
async def fetch_public_sheet(sheet_id: str, answer_key: Optional[dict] = None) -> dict:
|
| 42 |
-
"""
|
| 43 |
-
Fetch data from a PUBLIC Google Sheet (no OAuth needed).
|
| 44 |
-
The sheet must be shared as "Anyone with the link can view".
|
| 45 |
-
|
| 46 |
-
Uses the CSV export endpoint which works for public sheets.
|
| 47 |
-
"""
|
| 48 |
-
# Google Sheets public CSV export URL
|
| 49 |
-
csv_url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
| 50 |
-
|
| 51 |
-
try:
|
| 52 |
-
async with httpx.AsyncClient(follow_redirects=True) as client:
|
| 53 |
-
response = await client.get(csv_url, timeout=30.0)
|
| 54 |
-
|
| 55 |
-
if response.status_code == 200:
|
| 56 |
-
csv_content = response.text
|
| 57 |
-
|
| 58 |
-
# Check if we got actual CSV data
|
| 59 |
-
if csv_content.startswith('<!DOCTYPE') or '<html' in csv_content[:100].lower():
|
| 60 |
-
return {
|
| 61 |
-
"error": "This sheet is not public. Please make it 'Anyone with the link can view' or use CSV export.",
|
| 62 |
-
"needs_auth": False
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
# Parse the CSV
|
| 66 |
-
result = parse_csv_responses(csv_content, answer_key)
|
| 67 |
-
if "error" not in result:
|
| 68 |
-
result["exam_title"] = f"Google Sheet {sheet_id[:8]}..."
|
| 69 |
-
return result
|
| 70 |
-
|
| 71 |
-
elif response.status_code == 404:
|
| 72 |
-
return {
|
| 73 |
-
"error": "Sheet not found. Please check the URL.",
|
| 74 |
-
"needs_auth": False
|
| 75 |
-
}
|
| 76 |
-
else:
|
| 77 |
-
return {
|
| 78 |
-
"error": f"Could not access sheet (status {response.status_code}). Make sure it's set to 'Anyone with the link can view'.",
|
| 79 |
-
"needs_auth": False
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
except httpx.TimeoutException:
|
| 83 |
-
return {
|
| 84 |
-
"error": "Request timed out. Please try again.",
|
| 85 |
-
"needs_auth": False
|
| 86 |
-
}
|
| 87 |
-
except Exception as e:
|
| 88 |
-
return {
|
| 89 |
-
"error": f"Error fetching sheet: {str(e)}",
|
| 90 |
-
"needs_auth": False
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
async def get_google_credentials(teacher_email: str) -> Optional[Credentials]:
|
| 95 |
-
"""Get valid Google credentials for a teacher."""
|
| 96 |
-
tokens = await get_oauth_tokens(teacher_email)
|
| 97 |
-
if not tokens:
|
| 98 |
-
return None
|
| 99 |
-
|
| 100 |
-
settings = get_settings()
|
| 101 |
-
|
| 102 |
-
credentials = Credentials(
|
| 103 |
-
token=tokens.get("access_token"),
|
| 104 |
-
refresh_token=tokens.get("refresh_token"),
|
| 105 |
-
token_uri="https://oauth2.googleapis.com/token",
|
| 106 |
-
client_id=settings.google_client_id,
|
| 107 |
-
client_secret=settings.google_client_secret,
|
| 108 |
-
scopes=GOOGLE_SCOPES,
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
# Refresh if expired
|
| 112 |
-
if credentials.expired and credentials.refresh_token:
|
| 113 |
-
try:
|
| 114 |
-
credentials.refresh(Request())
|
| 115 |
-
# Save updated tokens
|
| 116 |
-
new_tokens = {
|
| 117 |
-
"access_token": credentials.token,
|
| 118 |
-
"refresh_token": credentials.refresh_token,
|
| 119 |
-
}
|
| 120 |
-
await save_oauth_tokens(teacher_email, new_tokens)
|
| 121 |
-
except Exception as e:
|
| 122 |
-
print(f"Error refreshing credentials: {e}")
|
| 123 |
-
return None
|
| 124 |
-
|
| 125 |
-
return credentials
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
def normalize_question_type(header: str, sample_answers: list[str]) -> str:
|
| 129 |
-
"""Infer question type from header and sample answers."""
|
| 130 |
-
header_lower = header.lower()
|
| 131 |
-
|
| 132 |
-
# Check for common patterns
|
| 133 |
-
if any(word in header_lower for word in ['multiple choice', 'mcq', 'select']):
|
| 134 |
-
return 'mcq'
|
| 135 |
-
|
| 136 |
-
# Check if answers are numeric
|
| 137 |
-
numeric_count = 0
|
| 138 |
-
for answer in sample_answers[:5]: # Check first 5 answers
|
| 139 |
-
if answer:
|
| 140 |
-
try:
|
| 141 |
-
float(answer.replace(',', ''))
|
| 142 |
-
numeric_count += 1
|
| 143 |
-
except ValueError:
|
| 144 |
-
pass
|
| 145 |
-
|
| 146 |
-
if numeric_count >= len([a for a in sample_answers[:5] if a]) * 0.8:
|
| 147 |
-
return 'numeric'
|
| 148 |
-
|
| 149 |
-
# Check for short answers (likely MCQ) vs long answers (open)
|
| 150 |
-
avg_length = sum(len(a) for a in sample_answers if a) / max(len([a for a in sample_answers if a]), 1)
|
| 151 |
-
if avg_length < 20:
|
| 152 |
-
return 'mcq'
|
| 153 |
-
|
| 154 |
-
return 'open'
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def extract_question_text(header: str) -> str:
|
| 158 |
-
"""Extract clean question text from a header."""
|
| 159 |
-
# Remove common prefixes like "Q1:", "1.", etc.
|
| 160 |
-
cleaned = re.sub(r'^[Q]?\d+[\.\:\)]\s*', '', header)
|
| 161 |
-
# Remove parenthetical scoring info like "(2 points)"
|
| 162 |
-
cleaned = re.sub(r'\s*\(\d+\s*(?:points?|pts?|marks?)\)\s*$', '', cleaned, flags=re.IGNORECASE)
|
| 163 |
-
return cleaned.strip() or header
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
async def fetch_google_form_responses(
|
| 167 |
-
google_form_url: str,
|
| 168 |
-
teacher_email: str,
|
| 169 |
-
answer_key: Optional[dict] = None
|
| 170 |
-
) -> dict:
|
| 171 |
-
"""
|
| 172 |
-
Fetch responses from a Google Form's response spreadsheet.
|
| 173 |
-
|
| 174 |
-
Returns normalized exam JSON format:
|
| 175 |
-
{
|
| 176 |
-
"exam_title": str,
|
| 177 |
-
"questions": [{"question_id", "question_text", "type", "choices", "correct_answer"}],
|
| 178 |
-
"responses": [{"student_id", "student_name", "answers": {...}}]
|
| 179 |
-
}
|
| 180 |
-
"""
|
| 181 |
-
credentials = await get_google_credentials(teacher_email)
|
| 182 |
-
if not credentials:
|
| 183 |
-
return {
|
| 184 |
-
"error": "Not authorized. Please connect your Google account first.",
|
| 185 |
-
"needs_auth": True
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
sheet_id = extract_sheet_id(google_form_url)
|
| 189 |
-
if not sheet_id:
|
| 190 |
-
return {
|
| 191 |
-
"error": "Could not extract spreadsheet ID from URL. Please provide a valid Google Sheets response URL.",
|
| 192 |
-
"needs_auth": False
|
| 193 |
-
}
|
| 194 |
-
|
| 195 |
-
try:
|
| 196 |
-
# Build the Sheets API service
|
| 197 |
-
service = build('sheets', 'v4', credentials=credentials)
|
| 198 |
-
|
| 199 |
-
# Get spreadsheet metadata
|
| 200 |
-
spreadsheet = service.spreadsheets().get(spreadsheetId=sheet_id).execute()
|
| 201 |
-
title = spreadsheet.get('properties', {}).get('title', 'Untitled Exam')
|
| 202 |
-
|
| 203 |
-
# Try to find "Form Responses 1" sheet, or use first sheet
|
| 204 |
-
sheet_name = None
|
| 205 |
-
for sheet in spreadsheet.get('sheets', []):
|
| 206 |
-
props = sheet.get('properties', {})
|
| 207 |
-
name = props.get('title', '')
|
| 208 |
-
if 'response' in name.lower() or 'form' in name.lower():
|
| 209 |
-
sheet_name = name
|
| 210 |
-
break
|
| 211 |
-
|
| 212 |
-
if not sheet_name:
|
| 213 |
-
sheet_name = spreadsheet['sheets'][0]['properties']['title']
|
| 214 |
-
|
| 215 |
-
# Fetch all values
|
| 216 |
-
result = service.spreadsheets().values().get(
|
| 217 |
-
spreadsheetId=sheet_id,
|
| 218 |
-
range=f"'{sheet_name}'!A:ZZ"
|
| 219 |
-
).execute()
|
| 220 |
-
|
| 221 |
-
values = result.get('values', [])
|
| 222 |
-
if not values or len(values) < 2:
|
| 223 |
-
return {
|
| 224 |
-
"error": "No responses found in the spreadsheet.",
|
| 225 |
-
"needs_auth": False
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
# First row is headers
|
| 229 |
-
headers = values[0]
|
| 230 |
-
responses_data = values[1:]
|
| 231 |
-
|
| 232 |
-
# Identify columns
|
| 233 |
-
# Typically: Timestamp, Email, Name, Q1, Q2, ...
|
| 234 |
-
timestamp_col = None
|
| 235 |
-
email_col = None
|
| 236 |
-
name_col = None
|
| 237 |
-
question_cols = []
|
| 238 |
-
|
| 239 |
-
for idx, header in enumerate(headers):
|
| 240 |
-
header_lower = header.lower()
|
| 241 |
-
if 'timestamp' in header_lower or 'time' in header_lower:
|
| 242 |
-
timestamp_col = idx
|
| 243 |
-
elif 'email' in header_lower and email_col is None:
|
| 244 |
-
email_col = idx
|
| 245 |
-
elif any(word in header_lower for word in ['name', 'student', 'your name']):
|
| 246 |
-
name_col = idx
|
| 247 |
-
else:
|
| 248 |
-
# This is likely a question
|
| 249 |
-
question_cols.append((idx, header))
|
| 250 |
-
|
| 251 |
-
# Build questions list
|
| 252 |
-
questions = []
|
| 253 |
-
for q_idx, (col_idx, header) in enumerate(question_cols):
|
| 254 |
-
question_id = f"Q{q_idx + 1}"
|
| 255 |
-
|
| 256 |
-
# Sample answers for type detection
|
| 257 |
-
sample_answers = [row[col_idx] if col_idx < len(row) else "" for row in responses_data[:10]]
|
| 258 |
-
|
| 259 |
-
question = {
|
| 260 |
-
"question_id": question_id,
|
| 261 |
-
"question_text": extract_question_text(header),
|
| 262 |
-
"type": normalize_question_type(header, sample_answers),
|
| 263 |
-
"choices": [], # Would need to parse from form structure
|
| 264 |
-
"correct_answer": ""
|
| 265 |
-
}
|
| 266 |
-
|
| 267 |
-
# Apply answer key if provided
|
| 268 |
-
if answer_key and question_id in answer_key:
|
| 269 |
-
question["correct_answer"] = answer_key[question_id]
|
| 270 |
-
|
| 271 |
-
questions.append(question)
|
| 272 |
-
|
| 273 |
-
# Build responses list
|
| 274 |
-
responses = []
|
| 275 |
-
for row_idx, row in enumerate(responses_data):
|
| 276 |
-
student_id = f"S{row_idx + 1:02d}"
|
| 277 |
-
|
| 278 |
-
# Get student name
|
| 279 |
-
if name_col is not None and name_col < len(row):
|
| 280 |
-
student_name = row[name_col]
|
| 281 |
-
elif email_col is not None and email_col < len(row):
|
| 282 |
-
# Use email prefix as name
|
| 283 |
-
email = row[email_col]
|
| 284 |
-
student_name = email.split('@')[0] if '@' in email else email
|
| 285 |
-
else:
|
| 286 |
-
student_name = f"Student {row_idx + 1}"
|
| 287 |
-
|
| 288 |
-
# Get answers
|
| 289 |
-
answers = {}
|
| 290 |
-
for q_idx, (col_idx, _) in enumerate(question_cols):
|
| 291 |
-
question_id = f"Q{q_idx + 1}"
|
| 292 |
-
answer = row[col_idx] if col_idx < len(row) else ""
|
| 293 |
-
answers[question_id] = answer
|
| 294 |
-
|
| 295 |
-
responses.append({
|
| 296 |
-
"student_id": student_id,
|
| 297 |
-
"student_name": student_name,
|
| 298 |
-
"answers": answers
|
| 299 |
-
})
|
| 300 |
-
|
| 301 |
-
return {
|
| 302 |
-
"exam_title": title,
|
| 303 |
-
"questions": questions,
|
| 304 |
-
"responses": responses
|
| 305 |
-
}
|
| 306 |
-
|
| 307 |
-
except HttpError as e:
|
| 308 |
-
if e.resp.status == 403:
|
| 309 |
-
return {
|
| 310 |
-
"error": "Access denied. Please ensure the spreadsheet is shared with your Google account.",
|
| 311 |
-
"needs_auth": False
|
| 312 |
-
}
|
| 313 |
-
elif e.resp.status == 404:
|
| 314 |
-
return {
|
| 315 |
-
"error": "Spreadsheet not found. Please check the URL.",
|
| 316 |
-
"needs_auth": False
|
| 317 |
-
}
|
| 318 |
-
else:
|
| 319 |
-
return {
|
| 320 |
-
"error": f"Google API error: {str(e)}",
|
| 321 |
-
"needs_auth": False
|
| 322 |
-
}
|
| 323 |
-
except Exception as e:
|
| 324 |
-
return {
|
| 325 |
-
"error": f"Error fetching responses: {str(e)}",
|
| 326 |
-
"needs_auth": False
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def parse_csv_responses(csv_content: str, answer_key: Optional[dict] = None) -> dict:
|
| 331 |
-
"""
|
| 332 |
-
Parse CSV content into normalized exam format.
|
| 333 |
-
Fallback when OAuth is not available.
|
| 334 |
-
"""
|
| 335 |
-
import csv
|
| 336 |
-
from io import StringIO
|
| 337 |
-
|
| 338 |
-
reader = csv.reader(StringIO(csv_content))
|
| 339 |
-
rows = list(reader)
|
| 340 |
-
|
| 341 |
-
if not rows or len(rows) < 2:
|
| 342 |
-
return {"error": "CSV must have at least a header row and one response."}
|
| 343 |
-
|
| 344 |
-
headers = rows[0]
|
| 345 |
-
responses_data = rows[1:]
|
| 346 |
-
|
| 347 |
-
# Same logic as Google Sheets parsing
|
| 348 |
-
timestamp_col = None
|
| 349 |
-
email_col = None
|
| 350 |
-
name_col = None
|
| 351 |
-
question_cols = []
|
| 352 |
-
|
| 353 |
-
for idx, header in enumerate(headers):
|
| 354 |
-
header_lower = header.lower()
|
| 355 |
-
if 'timestamp' in header_lower:
|
| 356 |
-
timestamp_col = idx
|
| 357 |
-
elif 'email' in header_lower and email_col is None:
|
| 358 |
-
email_col = idx
|
| 359 |
-
elif any(word in header_lower for word in ['name', 'student']):
|
| 360 |
-
name_col = idx
|
| 361 |
-
else:
|
| 362 |
-
question_cols.append((idx, header))
|
| 363 |
-
|
| 364 |
-
# Build questions
|
| 365 |
-
questions = []
|
| 366 |
-
for q_idx, (col_idx, header) in enumerate(question_cols):
|
| 367 |
-
question_id = f"Q{q_idx + 1}"
|
| 368 |
-
sample_answers = [row[col_idx] if col_idx < len(row) else "" for row in responses_data[:10]]
|
| 369 |
-
|
| 370 |
-
question = {
|
| 371 |
-
"question_id": question_id,
|
| 372 |
-
"question_text": extract_question_text(header),
|
| 373 |
-
"type": normalize_question_type(header, sample_answers),
|
| 374 |
-
"choices": [],
|
| 375 |
-
"correct_answer": answer_key.get(question_id, "") if answer_key else ""
|
| 376 |
-
}
|
| 377 |
-
questions.append(question)
|
| 378 |
-
|
| 379 |
-
# Build responses
|
| 380 |
-
responses = []
|
| 381 |
-
for row_idx, row in enumerate(responses_data):
|
| 382 |
-
student_id = f"S{row_idx + 1:02d}"
|
| 383 |
-
|
| 384 |
-
if name_col is not None and name_col < len(row):
|
| 385 |
-
student_name = row[name_col]
|
| 386 |
-
elif email_col is not None and email_col < len(row):
|
| 387 |
-
email = row[email_col]
|
| 388 |
-
student_name = email.split('@')[0] if '@' in email else email
|
| 389 |
-
else:
|
| 390 |
-
student_name = f"Student {row_idx + 1}"
|
| 391 |
-
|
| 392 |
-
answers = {}
|
| 393 |
-
for q_idx, (col_idx, _) in enumerate(question_cols):
|
| 394 |
-
question_id = f"Q{q_idx + 1}"
|
| 395 |
-
answer = row[col_idx] if col_idx < len(row) else ""
|
| 396 |
-
answers[question_id] = answer
|
| 397 |
-
|
| 398 |
-
responses.append({
|
| 399 |
-
"student_id": student_id,
|
| 400 |
-
"student_name": student_name,
|
| 401 |
-
"answers": answers
|
| 402 |
-
})
|
| 403 |
-
|
| 404 |
-
return {
|
| 405 |
-
"exam_title": "Uploaded Exam",
|
| 406 |
-
"questions": questions,
|
| 407 |
-
"responses": responses
|
| 408 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/main.py
CHANGED
|
@@ -1,45 +1,39 @@
|
|
| 1 |
-
"""FastAPI entrypoint for the ClassLens backend."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import os
|
| 6 |
-
import json
|
| 7 |
-
import asyncio
|
| 8 |
from pathlib import Path
|
| 9 |
from contextlib import asynccontextmanager
|
| 10 |
-
from typing import Optional
|
| 11 |
|
| 12 |
-
from
|
| 13 |
-
from fastapi import FastAPI, Request
|
| 14 |
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
-
from fastapi.responses import JSONResponse, Response,
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
| 19 |
-
from .
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
# Static files directory (for production deployment)
|
| 27 |
STATIC_DIR = Path(__file__).parent.parent / "static"
|
| 28 |
-
# Report template path (check multiple possible locations)
|
| 29 |
-
# Docker: /home/user/app/report-template.html (parent.parent from app/main.py)
|
| 30 |
-
# Dev: project_root/report-template.html (parent.parent.parent from backend/app/main.py)
|
| 31 |
-
def find_report_template():
|
| 32 |
-
"""Find report template in common locations."""
|
| 33 |
-
possible_paths = [
|
| 34 |
-
Path(__file__).parent.parent / "report-template.html", # Docker location
|
| 35 |
-
Path(__file__).parent.parent.parent / "report-template.html", # Dev location
|
| 36 |
-
]
|
| 37 |
-
for path in possible_paths:
|
| 38 |
-
if path.exists():
|
| 39 |
-
return path
|
| 40 |
-
return None
|
| 41 |
-
|
| 42 |
-
REPORT_TEMPLATE_PATH = find_report_template()
|
| 43 |
|
| 44 |
|
| 45 |
@asynccontextmanager
|
|
@@ -51,8 +45,8 @@ async def lifespan(app: FastAPI):
|
|
| 51 |
|
| 52 |
app = FastAPI(
|
| 53 |
title="ClassLens API",
|
| 54 |
-
description="AI-powered exam analysis for teachers",
|
| 55 |
-
version="
|
| 56 |
lifespan=lifespan,
|
| 57 |
)
|
| 58 |
|
|
@@ -64,329 +58,283 @@ app.add_middleware(
|
|
| 64 |
allow_headers=["*"],
|
| 65 |
)
|
| 66 |
|
| 67 |
-
# Include OAuth routes
|
| 68 |
-
app.include_router(oauth_router)
|
| 69 |
|
| 70 |
-
#
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
# =============================================================================
|
| 75 |
-
#
|
| 76 |
# =============================================================================
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
context = {
|
| 102 |
-
"request": request,
|
| 103 |
-
"model": model, # Pass model in context
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
# Also try to extract from payload if it's JSON
|
| 107 |
-
try:
|
| 108 |
-
import json
|
| 109 |
-
payload_json = json.loads(payload)
|
| 110 |
-
if isinstance(payload_json, dict):
|
| 111 |
-
# Check various possible locations for model
|
| 112 |
-
payload_model = (
|
| 113 |
-
payload_json.get("metadata", {}).get("model") or
|
| 114 |
-
payload_json.get("model") or
|
| 115 |
-
payload_json.get("config", {}).get("model")
|
| 116 |
-
)
|
| 117 |
-
if payload_model:
|
| 118 |
-
context["model"] = payload_model
|
| 119 |
-
except:
|
| 120 |
-
pass
|
| 121 |
-
|
| 122 |
-
result = await chatkit_server.process(payload, context)
|
| 123 |
-
|
| 124 |
-
if isinstance(result, StreamingResult):
|
| 125 |
-
return StreamingResponse(result, media_type="text/event-stream")
|
| 126 |
-
if hasattr(result, "json"):
|
| 127 |
-
return Response(content=result.json, media_type="application/json")
|
| 128 |
-
return JSONResponse(result)
|
| 129 |
|
| 130 |
|
| 131 |
# =============================================================================
|
| 132 |
-
#
|
| 133 |
# =============================================================================
|
| 134 |
|
| 135 |
-
@app.
|
| 136 |
-
async def
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
async def status_stream(session_id: str) -> AsyncIterator[str]:
|
| 142 |
-
"""Generate SSE events for status updates."""
|
| 143 |
-
queue = await subscribe(session_id)
|
| 144 |
try:
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
media_type="text/event-stream",
|
| 169 |
-
headers={
|
| 170 |
-
"Cache-Control": "no-cache",
|
| 171 |
-
"Connection": "keep-alive",
|
| 172 |
-
"X-Accel-Buffering": "no",
|
| 173 |
-
}
|
| 174 |
-
)
|
| 175 |
|
| 176 |
|
| 177 |
# =============================================================================
|
| 178 |
-
#
|
| 179 |
# =============================================================================
|
| 180 |
|
| 181 |
-
class
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
answer_key: Optional[dict] = None
|
| 185 |
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
| 190 |
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
@app.post("/api/fetch_google_form_responses")
|
| 199 |
-
async def api_fetch_responses(request: FetchResponsesRequest):
|
| 200 |
-
"""
|
| 201 |
-
Fetch and normalize responses from a Google Form/Sheets URL.
|
| 202 |
-
|
| 203 |
-
This endpoint requires the teacher to have connected their Google account.
|
| 204 |
-
"""
|
| 205 |
-
result = await fetch_google_form_responses(
|
| 206 |
-
request.google_form_url,
|
| 207 |
-
request.teacher_email,
|
| 208 |
-
request.answer_key
|
| 209 |
-
)
|
| 210 |
-
return result
|
| 211 |
|
| 212 |
|
| 213 |
-
@app.
|
| 214 |
-
async def
|
| 215 |
-
"""
|
| 216 |
-
|
| 217 |
-
"""
|
| 218 |
-
result = parse_csv_responses(request.csv_content, request.answer_key)
|
| 219 |
-
return result
|
| 220 |
|
| 221 |
|
| 222 |
-
@app.post("/api/
|
| 223 |
-
async def
|
| 224 |
-
""
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
result = await send_email_report(
|
| 228 |
-
request.email,
|
| 229 |
-
request.subject,
|
| 230 |
-
request.body_markdown
|
| 231 |
-
)
|
| 232 |
-
return result
|
| 233 |
|
| 234 |
|
| 235 |
-
@app.get("/api/
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
"
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
|
| 270 |
# =============================================================================
|
| 271 |
-
#
|
| 272 |
# =============================================================================
|
| 273 |
|
| 274 |
-
@app.get("/
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
base_dir = Path(__file__).parent.parent # /home/user/app in Docker
|
| 279 |
-
possible_paths = [
|
| 280 |
-
base_dir / "report-template.html", # Docker: /home/user/app/report-template.html
|
| 281 |
-
REPORT_TEMPLATE_PATH, # From find_report_template() (fallback)
|
| 282 |
-
STATIC_DIR / "report-template.html" if STATIC_DIR.exists() else None, # Static dir
|
| 283 |
-
Path(__file__).parent.parent.parent / "report-template.html", # Dev fallback
|
| 284 |
-
]
|
| 285 |
-
|
| 286 |
-
# Filter out None values
|
| 287 |
-
possible_paths = [p for p in possible_paths if p is not None]
|
| 288 |
-
|
| 289 |
-
for template_path in possible_paths:
|
| 290 |
-
if template_path and template_path.exists():
|
| 291 |
-
# Read file content and return with explicit headers
|
| 292 |
-
try:
|
| 293 |
-
content = template_path.read_text(encoding='utf-8')
|
| 294 |
-
return Response(
|
| 295 |
-
content=content,
|
| 296 |
-
media_type="text/html; charset=utf-8",
|
| 297 |
-
headers={
|
| 298 |
-
"Cache-Control": "public, max-age=3600",
|
| 299 |
-
"X-Content-Type-Options": "nosniff",
|
| 300 |
-
"Access-Control-Allow-Origin": "*",
|
| 301 |
-
}
|
| 302 |
-
)
|
| 303 |
-
except Exception as e:
|
| 304 |
-
# If reading fails, try FileResponse as fallback
|
| 305 |
-
return FileResponse(
|
| 306 |
-
template_path,
|
| 307 |
-
media_type="text/html; charset=utf-8",
|
| 308 |
-
headers={
|
| 309 |
-
"Cache-Control": "public, max-age=3600",
|
| 310 |
-
"Access-Control-Allow-Origin": "*",
|
| 311 |
-
}
|
| 312 |
-
)
|
| 313 |
-
|
| 314 |
-
# If not found, return helpful error message with debug info
|
| 315 |
-
current_dir = base_dir
|
| 316 |
-
files_in_dir = list(current_dir.glob("*")) if current_dir.exists() else []
|
| 317 |
-
searched_paths = [str(p) for p in possible_paths]
|
| 318 |
-
debug_info = {
|
| 319 |
-
"searched_paths": searched_paths,
|
| 320 |
-
"current_dir": str(current_dir),
|
| 321 |
-
"files_in_dir": [str(f.name) for f in files_in_dir[:10]],
|
| 322 |
-
"static_dir": str(STATIC_DIR) if STATIC_DIR.exists() else "does not exist",
|
| 323 |
-
"__file__": str(Path(__file__)),
|
| 324 |
-
}
|
| 325 |
-
return Response(
|
| 326 |
-
content=f"Report template not found.\nDebug info:\n{json.dumps(debug_info, indent=2)}",
|
| 327 |
-
status_code=404,
|
| 328 |
-
media_type="text/plain",
|
| 329 |
-
headers={"Access-Control-Allow-Origin": "*"}
|
| 330 |
-
)
|
| 331 |
|
| 332 |
|
| 333 |
# =============================================================================
|
| 334 |
# Static File Serving (Production)
|
| 335 |
# =============================================================================
|
| 336 |
|
| 337 |
-
|
| 338 |
-
if STATIC_DIR.exists():
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
@app.get("/favicon.ico")
|
| 343 |
async def favicon():
|
| 344 |
favicon_path = STATIC_DIR / "favicon.ico"
|
| 345 |
if favicon_path.exists():
|
| 346 |
return FileResponse(favicon_path)
|
| 347 |
return Response(status_code=404)
|
| 348 |
-
|
| 349 |
@app.get("/")
|
| 350 |
async def serve_spa():
|
| 351 |
-
"""Serve the React SPA."""
|
| 352 |
return FileResponse(STATIC_DIR / "index.html")
|
| 353 |
-
|
| 354 |
@app.get("/{full_path:path}")
|
| 355 |
async def serve_spa_routes(full_path: str):
|
| 356 |
-
""
|
| 357 |
-
# Don't serve SPA for API routes or specific endpoints
|
| 358 |
-
excluded_paths = ("api/", "auth/", "chatkit", "report-demo", "test-routes", "health")
|
| 359 |
-
if full_path.startswith(excluded_paths) or full_path in excluded_paths:
|
| 360 |
return Response(status_code=404)
|
| 361 |
-
|
| 362 |
-
# Check if it's a static file
|
| 363 |
file_path = STATIC_DIR / full_path
|
| 364 |
if file_path.exists() and file_path.is_file():
|
| 365 |
return FileResponse(file_path)
|
| 366 |
-
|
| 367 |
-
# Otherwise serve index.html for SPA routing
|
| 368 |
return FileResponse(STATIC_DIR / "index.html")
|
| 369 |
else:
|
| 370 |
-
# Development mode - show API info
|
| 371 |
@app.get("/")
|
| 372 |
async def root():
|
| 373 |
-
"""Root endpoint with API information (dev mode)."""
|
| 374 |
return {
|
| 375 |
"name": "ClassLens API",
|
| 376 |
-
"version": "
|
| 377 |
"mode": "development",
|
| 378 |
"description": "AI-powered exam analysis for teachers",
|
| 379 |
-
"note": "Frontend served separately on port 3000",
|
| 380 |
-
"endpoints": {
|
| 381 |
-
"chatkit": "/chatkit",
|
| 382 |
-
"oauth_start": "/auth/start?teacher_email=...",
|
| 383 |
-
"oauth_callback": "/auth/callback",
|
| 384 |
-
"auth_status": "/auth/status?teacher_email=...",
|
| 385 |
-
"workflow_status": "/api/status/{session_id}",
|
| 386 |
-
"workflow_stream": "/api/status/{session_id}/stream",
|
| 387 |
-
"fetch_responses": "/api/fetch_google_form_responses",
|
| 388 |
-
"parse_csv": "/api/parse_csv",
|
| 389 |
-
"send_email": "/api/send_email_report",
|
| 390 |
-
"health": "/api/health",
|
| 391 |
-
}
|
| 392 |
}
|
|
|
|
| 1 |
+
"""FastAPI entrypoint for the ClassLens backend (v2 - 3-step workflow)."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from contextlib import asynccontextmanager
|
| 7 |
+
from typing import Optional
|
| 8 |
|
| 9 |
+
from fastapi import FastAPI, Depends, File, Form, UploadFile, HTTPException
|
|
|
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import JSONResponse, Response, FileResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
from pydantic import BaseModel
|
| 14 |
|
| 15 |
+
from .database import (
|
| 16 |
+
init_database,
|
| 17 |
+
create_session,
|
| 18 |
+
get_sessions,
|
| 19 |
+
get_session,
|
| 20 |
+
update_session_status,
|
| 21 |
+
get_parsed_data,
|
| 22 |
+
save_prompt,
|
| 23 |
+
get_prompts,
|
| 24 |
+
get_all_prompts,
|
| 25 |
+
get_prompt,
|
| 26 |
+
update_prompt,
|
| 27 |
+
delete_prompt,
|
| 28 |
+
save_report,
|
| 29 |
+
get_reports,
|
| 30 |
+
get_report,
|
| 31 |
+
)
|
| 32 |
+
from .auth import get_current_user, register_user, login_user
|
| 33 |
+
from .file_processor import process_uploaded_files
|
| 34 |
+
from .report_generator import generate_report, get_default_prompt
|
| 35 |
|
|
|
|
| 36 |
STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
@asynccontextmanager
|
|
|
|
| 45 |
|
| 46 |
app = FastAPI(
|
| 47 |
title="ClassLens API",
|
| 48 |
+
description="AI-powered exam analysis for teachers (v2)",
|
| 49 |
+
version="2.0.0",
|
| 50 |
lifespan=lifespan,
|
| 51 |
)
|
| 52 |
|
|
|
|
| 58 |
allow_headers=["*"],
|
| 59 |
)
|
| 60 |
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
# =============================================================================
|
| 63 |
+
# Auth Endpoints
|
| 64 |
+
# =============================================================================
|
| 65 |
+
|
| 66 |
+
class AuthRequest(BaseModel):
|
| 67 |
+
email: str
|
| 68 |
+
password: str
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.post("/api/auth/register")
|
| 72 |
+
async def api_register(body: AuthRequest):
|
| 73 |
+
user = await register_user(body.email, body.password)
|
| 74 |
+
from .auth import create_access_token
|
| 75 |
+
token = create_access_token({"sub": str(user["id"])})
|
| 76 |
+
return {"token": token, "user": {"id": user["id"], "email": user["email"], "display_name": user["display_name"]}}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@app.post("/api/auth/login")
|
| 80 |
+
async def api_login(body: AuthRequest):
|
| 81 |
+
result = await login_user(body.email, body.password)
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@app.get("/api/auth/me")
|
| 86 |
+
async def api_me(user=Depends(get_current_user)):
|
| 87 |
+
return {"id": user["id"], "email": user["email"], "display_name": user["display_name"]}
|
| 88 |
|
| 89 |
|
| 90 |
# =============================================================================
|
| 91 |
+
# Session Endpoints
|
| 92 |
# =============================================================================
|
| 93 |
|
| 94 |
+
class CreateSessionRequest(BaseModel):
|
| 95 |
+
title: str = "Untitled Session"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.post("/api/sessions")
|
| 99 |
+
async def api_create_session(body: CreateSessionRequest, user=Depends(get_current_user)):
|
| 100 |
+
session_id = await create_session(user["id"], body.title)
|
| 101 |
+
session = await get_session(session_id)
|
| 102 |
+
return session
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@app.get("/api/sessions")
|
| 106 |
+
async def api_list_sessions(user=Depends(get_current_user)):
|
| 107 |
+
sessions = await get_sessions(user["id"])
|
| 108 |
+
return {"sessions": sessions}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.get("/api/sessions/{session_id}")
|
| 112 |
+
async def api_get_session(session_id: int, user=Depends(get_current_user)):
|
| 113 |
+
session = await get_session(session_id)
|
| 114 |
+
if not session or session["user_id"] != user["id"]:
|
| 115 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 116 |
+
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
# =============================================================================
|
| 120 |
+
# File Upload & Processing Endpoints
|
| 121 |
# =============================================================================
|
| 122 |
|
| 123 |
+
@app.post("/api/sessions/{session_id}/upload")
|
| 124 |
+
async def api_upload_files(
|
| 125 |
+
session_id: int,
|
| 126 |
+
data_type: str = Form(...),
|
| 127 |
+
files: list[UploadFile] = File(...),
|
| 128 |
+
description: str = Form(""),
|
| 129 |
+
model: str = Form("gpt-5.4"),
|
| 130 |
+
user=Depends(get_current_user),
|
| 131 |
+
):
|
| 132 |
+
"""Upload files for a session. data_type: 'questions', 'student_answers', or 'teacher_answers'."""
|
| 133 |
+
session = await get_session(session_id)
|
| 134 |
+
if not session or session["user_id"] != user["id"]:
|
| 135 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 136 |
+
|
| 137 |
+
if data_type not in ("questions", "student_answers", "teacher_answers"):
|
| 138 |
+
raise HTTPException(status_code=400, detail="data_type must be 'questions', 'student_answers', or 'teacher_answers'")
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
try:
|
| 141 |
+
structured = await process_uploaded_files(files, data_type, session_id, description=description, model=model)
|
| 142 |
+
await update_session_status(session_id, "processed")
|
| 143 |
+
return {"status": "ok", "data_type": data_type, "data": structured}
|
| 144 |
+
except ValueError as e:
|
| 145 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 146 |
+
except Exception as e:
|
| 147 |
+
raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@app.get("/api/sessions/{session_id}/parsed-data")
|
| 151 |
+
async def api_get_parsed_data(session_id: int, user=Depends(get_current_user)):
|
| 152 |
+
session = await get_session(session_id)
|
| 153 |
+
if not session or session["user_id"] != user["id"]:
|
| 154 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 155 |
+
data = await get_parsed_data(session_id)
|
| 156 |
+
# Group by data_type
|
| 157 |
+
grouped = {}
|
| 158 |
+
for item in data:
|
| 159 |
+
dt = item["data_type"]
|
| 160 |
+
if dt not in grouped:
|
| 161 |
+
grouped[dt] = []
|
| 162 |
+
grouped[dt].append(item)
|
| 163 |
+
return {"parsed_data": grouped}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
# =============================================================================
|
| 167 |
+
# Prompt Endpoints
|
| 168 |
# =============================================================================
|
| 169 |
|
| 170 |
+
class PromptRequest(BaseModel):
|
| 171 |
+
name: str
|
| 172 |
+
content: str
|
|
|
|
| 173 |
|
| 174 |
|
| 175 |
+
@app.get("/api/prompts")
|
| 176 |
+
async def api_list_prompts(user=Depends(get_current_user)):
|
| 177 |
+
prompts = await get_prompts(user["id"])
|
| 178 |
+
return {"prompts": prompts}
|
| 179 |
|
| 180 |
|
| 181 |
+
@app.get("/api/prompts/all")
|
| 182 |
+
async def api_list_all_prompts(user=Depends(get_current_user)):
|
| 183 |
+
"""List all prompts from all users (read others' prompts)."""
|
| 184 |
+
prompts = await get_all_prompts()
|
| 185 |
+
return {"prompts": prompts}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
+
@app.get("/api/prompts/default")
|
| 189 |
+
async def api_get_default_prompt():
|
| 190 |
+
"""Get the default analysis prompt template."""
|
| 191 |
+
return {"content": get_default_prompt()}
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
+
@app.post("/api/prompts")
|
| 195 |
+
async def api_save_prompt(body: PromptRequest, user=Depends(get_current_user)):
|
| 196 |
+
prompt_id = await save_prompt(user["id"], body.name, body.content)
|
| 197 |
+
prompt = await get_prompt(prompt_id)
|
| 198 |
+
return prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
+
@app.get("/api/prompts/{prompt_id}")
|
| 202 |
+
async def api_get_prompt(prompt_id: int, user=Depends(get_current_user)):
|
| 203 |
+
prompt = await get_prompt(prompt_id)
|
| 204 |
+
if not prompt:
|
| 205 |
+
raise HTTPException(status_code=404, detail="Prompt not found")
|
| 206 |
+
return prompt
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@app.put("/api/prompts/{prompt_id}")
|
| 210 |
+
async def api_update_prompt(prompt_id: int, body: PromptRequest, user=Depends(get_current_user)):
|
| 211 |
+
prompt = await get_prompt(prompt_id)
|
| 212 |
+
if not prompt or prompt["user_id"] != user["id"]:
|
| 213 |
+
raise HTTPException(status_code=404, detail="Prompt not found")
|
| 214 |
+
await update_prompt(prompt_id, body.name, body.content)
|
| 215 |
+
return await get_prompt(prompt_id)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@app.delete("/api/prompts/{prompt_id}")
|
| 219 |
+
async def api_delete_prompt(prompt_id: int, user=Depends(get_current_user)):
|
| 220 |
+
prompt = await get_prompt(prompt_id)
|
| 221 |
+
if not prompt or prompt["user_id"] != user["id"]:
|
| 222 |
+
raise HTTPException(status_code=404, detail="Prompt not found")
|
| 223 |
+
await delete_prompt(prompt_id)
|
| 224 |
+
return {"status": "deleted"}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# =============================================================================
|
| 228 |
+
# Report Generation Endpoints
|
| 229 |
+
# =============================================================================
|
| 230 |
+
|
| 231 |
+
class GenerateReportRequest(BaseModel):
|
| 232 |
+
prompt_id: Optional[int] = None
|
| 233 |
+
prompt_content: Optional[str] = None
|
| 234 |
+
model: str = "gpt-5.4"
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@app.post("/api/sessions/{session_id}/generate-report")
|
| 238 |
+
async def api_generate_report(
|
| 239 |
+
session_id: int, body: GenerateReportRequest, user=Depends(get_current_user)
|
| 240 |
+
):
|
| 241 |
+
session = await get_session(session_id)
|
| 242 |
+
if not session or session["user_id"] != user["id"]:
|
| 243 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 244 |
+
|
| 245 |
+
# Load parsed data
|
| 246 |
+
data = await get_parsed_data(session_id)
|
| 247 |
+
questions = {}
|
| 248 |
+
student_answers = {}
|
| 249 |
+
teacher_answers = {}
|
| 250 |
+
for item in data:
|
| 251 |
+
if item["data_type"] == "questions":
|
| 252 |
+
questions = item["structured_data"]
|
| 253 |
+
elif item["data_type"] == "student_answers":
|
| 254 |
+
student_answers = item["structured_data"]
|
| 255 |
+
elif item["data_type"] == "teacher_answers":
|
| 256 |
+
teacher_answers = item["structured_data"]
|
| 257 |
+
|
| 258 |
+
if not questions:
|
| 259 |
+
raise HTTPException(status_code=400, detail="No questions data found. Upload and process files first.")
|
| 260 |
+
|
| 261 |
+
# Get prompt content
|
| 262 |
+
prompt_content = body.prompt_content
|
| 263 |
+
if not prompt_content and body.prompt_id:
|
| 264 |
+
prompt = await get_prompt(body.prompt_id)
|
| 265 |
+
if prompt:
|
| 266 |
+
prompt_content = prompt["content"]
|
| 267 |
+
if not prompt_content:
|
| 268 |
+
prompt_content = get_default_prompt()
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
html = await generate_report(questions, student_answers, teacher_answers, prompt_content, body.model)
|
| 272 |
+
report_id = await save_report(session_id, body.prompt_id, html)
|
| 273 |
+
return {"report_id": report_id, "html_content": html}
|
| 274 |
+
except Exception as e:
|
| 275 |
+
raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@app.get("/api/sessions/{session_id}/reports")
|
| 279 |
+
async def api_list_reports(session_id: int, user=Depends(get_current_user)):
|
| 280 |
+
session = await get_session(session_id)
|
| 281 |
+
if not session or session["user_id"] != user["id"]:
|
| 282 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 283 |
+
reports = await get_reports(session_id)
|
| 284 |
+
return {"reports": reports}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@app.get("/api/reports/{report_id}")
|
| 288 |
+
async def api_get_report(report_id: int, user=Depends(get_current_user)):
|
| 289 |
+
report = await get_report(report_id)
|
| 290 |
+
if not report:
|
| 291 |
+
raise HTTPException(status_code=404, detail="Report not found")
|
| 292 |
+
return report
|
| 293 |
|
| 294 |
|
| 295 |
# =============================================================================
|
| 296 |
+
# Health Check
|
| 297 |
# =============================================================================
|
| 298 |
|
| 299 |
+
@app.get("/api/health")
|
| 300 |
+
@app.get("/health")
|
| 301 |
+
async def health_check():
|
| 302 |
+
return {"status": "healthy", "service": "ClassLens", "version": "2.0.0"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
|
| 305 |
# =============================================================================
|
| 306 |
# Static File Serving (Production)
|
| 307 |
# =============================================================================
|
| 308 |
|
| 309 |
+
if STATIC_DIR.exists() and (STATIC_DIR / "index.html").exists():
|
| 310 |
+
if (STATIC_DIR / "assets").exists():
|
| 311 |
+
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
| 312 |
+
|
|
|
|
| 313 |
@app.get("/favicon.ico")
|
| 314 |
async def favicon():
|
| 315 |
favicon_path = STATIC_DIR / "favicon.ico"
|
| 316 |
if favicon_path.exists():
|
| 317 |
return FileResponse(favicon_path)
|
| 318 |
return Response(status_code=404)
|
| 319 |
+
|
| 320 |
@app.get("/")
|
| 321 |
async def serve_spa():
|
|
|
|
| 322 |
return FileResponse(STATIC_DIR / "index.html")
|
| 323 |
+
|
| 324 |
@app.get("/{full_path:path}")
|
| 325 |
async def serve_spa_routes(full_path: str):
|
| 326 |
+
if full_path.startswith("api/") or full_path == "health":
|
|
|
|
|
|
|
|
|
|
| 327 |
return Response(status_code=404)
|
|
|
|
|
|
|
| 328 |
file_path = STATIC_DIR / full_path
|
| 329 |
if file_path.exists() and file_path.is_file():
|
| 330 |
return FileResponse(file_path)
|
|
|
|
|
|
|
| 331 |
return FileResponse(STATIC_DIR / "index.html")
|
| 332 |
else:
|
|
|
|
| 333 |
@app.get("/")
|
| 334 |
async def root():
|
|
|
|
| 335 |
return {
|
| 336 |
"name": "ClassLens API",
|
| 337 |
+
"version": "2.0.0",
|
| 338 |
"mode": "development",
|
| 339 |
"description": "AI-powered exam analysis for teachers",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
}
|
chatkit/backend/app/memory_store.py
DELETED
|
@@ -1,115 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Simple in-memory store compatible with the ChatKit Store interface.
|
| 3 |
-
A production app would implement this using a persistant database.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
from collections import defaultdict
|
| 9 |
-
|
| 10 |
-
from chatkit.store import NotFoundError, Store
|
| 11 |
-
from chatkit.types import Attachment, Page, ThreadItem, ThreadMetadata
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class MemoryStore(Store[dict]):
|
| 15 |
-
def __init__(self):
|
| 16 |
-
self.threads: dict[str, ThreadMetadata] = {}
|
| 17 |
-
self.items: dict[str, list[ThreadItem]] = defaultdict(list)
|
| 18 |
-
|
| 19 |
-
async def load_thread(self, thread_id: str, context: dict) -> ThreadMetadata:
|
| 20 |
-
if thread_id not in self.threads:
|
| 21 |
-
raise NotFoundError(f"Thread {thread_id} not found")
|
| 22 |
-
return self.threads[thread_id]
|
| 23 |
-
|
| 24 |
-
async def save_thread(self, thread: ThreadMetadata, context: dict) -> None:
|
| 25 |
-
self.threads[thread.id] = thread
|
| 26 |
-
|
| 27 |
-
async def load_threads(
|
| 28 |
-
self, limit: int, after: str | None, order: str, context: dict
|
| 29 |
-
) -> Page[ThreadMetadata]:
|
| 30 |
-
threads = list(self.threads.values())
|
| 31 |
-
return self._paginate(
|
| 32 |
-
threads,
|
| 33 |
-
after,
|
| 34 |
-
limit,
|
| 35 |
-
order,
|
| 36 |
-
sort_key=lambda t: t.created_at,
|
| 37 |
-
cursor_key=lambda t: t.id,
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
-
async def load_thread_items(
|
| 41 |
-
self, thread_id: str, after: str | None, limit: int, order: str, context: dict
|
| 42 |
-
) -> Page[ThreadItem]:
|
| 43 |
-
items = self.items.get(thread_id, [])
|
| 44 |
-
return self._paginate(
|
| 45 |
-
items,
|
| 46 |
-
after,
|
| 47 |
-
limit,
|
| 48 |
-
order,
|
| 49 |
-
sort_key=lambda i: i.created_at,
|
| 50 |
-
cursor_key=lambda i: i.id,
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
async def add_thread_item(
|
| 54 |
-
self, thread_id: str, item: ThreadItem, context: dict
|
| 55 |
-
) -> None:
|
| 56 |
-
self.items[thread_id].append(item)
|
| 57 |
-
|
| 58 |
-
async def save_item(self, thread_id: str, item: ThreadItem, context: dict) -> None:
|
| 59 |
-
items = self.items[thread_id]
|
| 60 |
-
for idx, existing in enumerate(items):
|
| 61 |
-
if existing.id == item.id:
|
| 62 |
-
items[idx] = item
|
| 63 |
-
return
|
| 64 |
-
items.append(item)
|
| 65 |
-
|
| 66 |
-
async def load_item(
|
| 67 |
-
self, thread_id: str, item_id: str, context: dict
|
| 68 |
-
) -> ThreadItem:
|
| 69 |
-
for item in self.items.get(thread_id, []):
|
| 70 |
-
if item.id == item_id:
|
| 71 |
-
return item
|
| 72 |
-
raise NotFoundError(f"Item {item_id} not found in thread {thread_id}")
|
| 73 |
-
|
| 74 |
-
async def delete_thread(self, thread_id: str, context: dict) -> None:
|
| 75 |
-
self.threads.pop(thread_id, None)
|
| 76 |
-
self.items.pop(thread_id, None)
|
| 77 |
-
|
| 78 |
-
async def delete_thread_item(
|
| 79 |
-
self, thread_id: str, item_id: str, context: dict
|
| 80 |
-
) -> None:
|
| 81 |
-
self.items[thread_id] = [
|
| 82 |
-
item for item in self.items.get(thread_id, []) if item.id != item_id
|
| 83 |
-
]
|
| 84 |
-
|
| 85 |
-
def _paginate(
|
| 86 |
-
self,
|
| 87 |
-
rows: list,
|
| 88 |
-
after: str | None,
|
| 89 |
-
limit: int,
|
| 90 |
-
order: str,
|
| 91 |
-
sort_key,
|
| 92 |
-
cursor_key,
|
| 93 |
-
):
|
| 94 |
-
sorted_rows = sorted(rows, key=sort_key, reverse=order == "desc")
|
| 95 |
-
start = 0
|
| 96 |
-
if after:
|
| 97 |
-
for idx, row in enumerate(sorted_rows):
|
| 98 |
-
if cursor_key(row) == after:
|
| 99 |
-
start = idx + 1
|
| 100 |
-
break
|
| 101 |
-
data = sorted_rows[start : start + limit]
|
| 102 |
-
has_more = start + limit < len(sorted_rows)
|
| 103 |
-
next_after = cursor_key(data[-1]) if has_more and data else None
|
| 104 |
-
return Page(data=data, has_more=has_more, after=next_after)
|
| 105 |
-
|
| 106 |
-
# Attachments are not implemented in the quickstart store
|
| 107 |
-
|
| 108 |
-
async def save_attachment(self, attachment: Attachment, context: dict) -> None:
|
| 109 |
-
raise NotImplementedError()
|
| 110 |
-
|
| 111 |
-
async def load_attachment(self, attachment_id: str, context: dict) -> Attachment:
|
| 112 |
-
raise NotImplementedError()
|
| 113 |
-
|
| 114 |
-
async def delete_attachment(self, attachment_id: str, context: dict) -> None:
|
| 115 |
-
raise NotImplementedError()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/oauth.py
DELETED
|
@@ -1,251 +0,0 @@
|
|
| 1 |
-
"""Google OAuth2 authentication endpoints."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import json
|
| 6 |
-
from urllib.parse import urlencode
|
| 7 |
-
from typing import Optional
|
| 8 |
-
|
| 9 |
-
from fastapi import APIRouter, Request, HTTPException
|
| 10 |
-
from fastapi.responses import RedirectResponse
|
| 11 |
-
import httpx
|
| 12 |
-
|
| 13 |
-
from .config import get_settings, GOOGLE_SCOPES
|
| 14 |
-
from .database import save_oauth_tokens, get_oauth_tokens, delete_oauth_tokens
|
| 15 |
-
|
| 16 |
-
router = APIRouter(prefix="/auth", tags=["authentication"])
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# Store state tokens temporarily (in production, use Redis or similar)
|
| 20 |
-
_state_store: dict[str, str] = {}
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def get_redirect_uri(request: Request, settings) -> str:
|
| 24 |
-
"""Get the OAuth redirect URI, auto-detecting from request if not configured."""
|
| 25 |
-
if settings.google_redirect_uri and settings.google_redirect_uri != "http://localhost:8000/auth/callback":
|
| 26 |
-
return settings.google_redirect_uri
|
| 27 |
-
|
| 28 |
-
# Auto-detect from request
|
| 29 |
-
# Check for common proxy headers first
|
| 30 |
-
forwarded_proto = request.headers.get("x-forwarded-proto", "http")
|
| 31 |
-
forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost:8000")
|
| 32 |
-
|
| 33 |
-
# Normalize 127.0.0.1 to localhost for local development (Google OAuth requires exact match)
|
| 34 |
-
if forwarded_host.startswith("127.0.0.1"):
|
| 35 |
-
forwarded_host = forwarded_host.replace("127.0.0.1", "localhost")
|
| 36 |
-
|
| 37 |
-
# Build the redirect URI
|
| 38 |
-
return f"{forwarded_proto}://{forwarded_host}/auth/callback"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
@router.get("/start")
|
| 42 |
-
async def start_auth(teacher_email: str, request: Request):
|
| 43 |
-
"""
|
| 44 |
-
Start OAuth flow by redirecting to Google consent screen.
|
| 45 |
-
|
| 46 |
-
Query params:
|
| 47 |
-
teacher_email: The teacher's email address
|
| 48 |
-
"""
|
| 49 |
-
settings = get_settings()
|
| 50 |
-
|
| 51 |
-
if not settings.google_client_id or not settings.google_client_secret:
|
| 52 |
-
raise HTTPException(
|
| 53 |
-
status_code=500,
|
| 54 |
-
detail="Google OAuth not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET."
|
| 55 |
-
)
|
| 56 |
-
|
| 57 |
-
# Generate state token for CSRF protection
|
| 58 |
-
import secrets
|
| 59 |
-
state = secrets.token_urlsafe(32)
|
| 60 |
-
|
| 61 |
-
# Get the redirect URI (auto-detect or from config)
|
| 62 |
-
redirect_uri = get_redirect_uri(request, settings)
|
| 63 |
-
|
| 64 |
-
# Store both email and redirect_uri in state for callback
|
| 65 |
-
_state_store[state] = {"email": teacher_email, "redirect_uri": redirect_uri}
|
| 66 |
-
|
| 67 |
-
# Build authorization URL
|
| 68 |
-
# Use "select_account consent" to allow account selection first, then show consent
|
| 69 |
-
# This is more flexible and works better when user isn't logged in
|
| 70 |
-
auth_params = {
|
| 71 |
-
"client_id": settings.google_client_id,
|
| 72 |
-
"redirect_uri": redirect_uri,
|
| 73 |
-
"response_type": "code",
|
| 74 |
-
"scope": " ".join(GOOGLE_SCOPES),
|
| 75 |
-
"access_type": "offline", # Get refresh token
|
| 76 |
-
"prompt": "select_account consent", # Allow account selection, then show consent
|
| 77 |
-
"state": state,
|
| 78 |
-
# Only add login_hint if email is provided and valid
|
| 79 |
-
# This helps pre-fill but doesn't break if user isn't logged in
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
# Add login_hint only if email looks valid (helps pre-fill but not required)
|
| 83 |
-
if teacher_email and "@" in teacher_email:
|
| 84 |
-
auth_params["login_hint"] = teacher_email
|
| 85 |
-
|
| 86 |
-
print(f"[OAuth] Starting auth for {teacher_email}")
|
| 87 |
-
print(f"[OAuth] Redirect URI: {redirect_uri}")
|
| 88 |
-
print(f"[OAuth] Scopes: {GOOGLE_SCOPES}")
|
| 89 |
-
print(f"[OAuth] Full auth URL: https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}")
|
| 90 |
-
|
| 91 |
-
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}"
|
| 92 |
-
return RedirectResponse(url=auth_url)
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def get_frontend_url(request: Request, settings) -> str:
|
| 96 |
-
"""Get the frontend URL, auto-detecting from request if not configured."""
|
| 97 |
-
if settings.frontend_url and settings.frontend_url != "http://localhost:3000":
|
| 98 |
-
return settings.frontend_url
|
| 99 |
-
|
| 100 |
-
# Auto-detect from request (same origin for full-stack deployment)
|
| 101 |
-
forwarded_proto = request.headers.get("x-forwarded-proto", "http")
|
| 102 |
-
forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost:3000")
|
| 103 |
-
|
| 104 |
-
return f"{forwarded_proto}://{forwarded_host}"
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
@router.get("/callback")
|
| 108 |
-
async def auth_callback(request: Request, code: str = None, state: str = None, error: str = None, error_description: str = None):
|
| 109 |
-
"""
|
| 110 |
-
Handle OAuth callback from Google.
|
| 111 |
-
|
| 112 |
-
Query params:
|
| 113 |
-
code: Authorization code from Google
|
| 114 |
-
state: State token for CSRF verification
|
| 115 |
-
error: Error message if authorization failed
|
| 116 |
-
error_description: Detailed error description from Google
|
| 117 |
-
"""
|
| 118 |
-
settings = get_settings()
|
| 119 |
-
frontend_url = get_frontend_url(request, settings)
|
| 120 |
-
|
| 121 |
-
if error:
|
| 122 |
-
# Log the full error details
|
| 123 |
-
error_msg = error
|
| 124 |
-
if error_description:
|
| 125 |
-
error_msg = f"{error}: {error_description}"
|
| 126 |
-
print(f"[OAuth] Error callback: {error_msg}")
|
| 127 |
-
print(f"[OAuth] Full query params: {dict(request.query_params)}")
|
| 128 |
-
return RedirectResponse(
|
| 129 |
-
url=f"{frontend_url}?auth_error={error_msg}"
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
if not code or not state:
|
| 133 |
-
return RedirectResponse(
|
| 134 |
-
url=f"{frontend_url}?auth_error=missing_params"
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
# Verify state and get stored data
|
| 138 |
-
state_data = _state_store.pop(state, None)
|
| 139 |
-
if not state_data:
|
| 140 |
-
return RedirectResponse(
|
| 141 |
-
url=f"{frontend_url}?auth_error=invalid_state"
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
teacher_email = state_data["email"]
|
| 145 |
-
redirect_uri = state_data["redirect_uri"]
|
| 146 |
-
|
| 147 |
-
print(f"[OAuth] Callback for {teacher_email}")
|
| 148 |
-
print(f"[OAuth] Using redirect_uri: {redirect_uri}")
|
| 149 |
-
|
| 150 |
-
# Exchange code for tokens
|
| 151 |
-
try:
|
| 152 |
-
async with httpx.AsyncClient() as client:
|
| 153 |
-
token_response = await client.post(
|
| 154 |
-
"https://oauth2.googleapis.com/token",
|
| 155 |
-
data={
|
| 156 |
-
"client_id": settings.google_client_id,
|
| 157 |
-
"client_secret": settings.google_client_secret,
|
| 158 |
-
"code": code,
|
| 159 |
-
"grant_type": "authorization_code",
|
| 160 |
-
"redirect_uri": redirect_uri,
|
| 161 |
-
},
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
if token_response.status_code != 200:
|
| 165 |
-
print(f"[OAuth] Token exchange failed: {token_response.text}")
|
| 166 |
-
return RedirectResponse(
|
| 167 |
-
url=f"{frontend_url}?auth_error=token_exchange_failed"
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
tokens = token_response.json()
|
| 171 |
-
|
| 172 |
-
# Verify the user's email matches
|
| 173 |
-
userinfo_response = await client.get(
|
| 174 |
-
"https://www.googleapis.com/oauth2/v2/userinfo",
|
| 175 |
-
headers={"Authorization": f"Bearer {tokens['access_token']}"}
|
| 176 |
-
)
|
| 177 |
-
|
| 178 |
-
if userinfo_response.status_code == 200:
|
| 179 |
-
userinfo = userinfo_response.json()
|
| 180 |
-
verified_email = userinfo.get("email", "")
|
| 181 |
-
|
| 182 |
-
# Use the verified email from Google
|
| 183 |
-
if verified_email:
|
| 184 |
-
teacher_email = verified_email
|
| 185 |
-
|
| 186 |
-
# Save tokens
|
| 187 |
-
await save_oauth_tokens(teacher_email, {
|
| 188 |
-
"access_token": tokens.get("access_token"),
|
| 189 |
-
"refresh_token": tokens.get("refresh_token"),
|
| 190 |
-
"expires_in": tokens.get("expires_in"),
|
| 191 |
-
})
|
| 192 |
-
|
| 193 |
-
print(f"[OAuth] Success for {teacher_email}")
|
| 194 |
-
return RedirectResponse(
|
| 195 |
-
url=f"{frontend_url}?auth_success=true&email={teacher_email}"
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
except Exception as e:
|
| 199 |
-
print(f"[OAuth] Error: {e}")
|
| 200 |
-
return RedirectResponse(
|
| 201 |
-
url=f"{frontend_url}?auth_error={str(e)}"
|
| 202 |
-
)
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
@router.get("/status")
|
| 206 |
-
async def auth_status(teacher_email: str):
|
| 207 |
-
"""
|
| 208 |
-
Check if a teacher is authenticated.
|
| 209 |
-
|
| 210 |
-
Query params:
|
| 211 |
-
teacher_email: The teacher's email address
|
| 212 |
-
"""
|
| 213 |
-
tokens = await get_oauth_tokens(teacher_email)
|
| 214 |
-
return {
|
| 215 |
-
"authenticated": tokens is not None,
|
| 216 |
-
"email": teacher_email if tokens else None
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
@router.post("/disconnect")
|
| 221 |
-
async def disconnect(teacher_email: str):
|
| 222 |
-
"""
|
| 223 |
-
Disconnect a teacher's Google account.
|
| 224 |
-
|
| 225 |
-
Query params:
|
| 226 |
-
teacher_email: The teacher's email address
|
| 227 |
-
"""
|
| 228 |
-
await delete_oauth_tokens(teacher_email)
|
| 229 |
-
return {"status": "ok", "message": "Google account disconnected"}
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
@router.get("/debug")
|
| 233 |
-
async def debug_oauth(request: Request):
|
| 234 |
-
"""Debug endpoint to check OAuth configuration."""
|
| 235 |
-
settings = get_settings()
|
| 236 |
-
redirect_uri = get_redirect_uri(request, settings)
|
| 237 |
-
frontend_url = get_frontend_url(request, settings)
|
| 238 |
-
|
| 239 |
-
return {
|
| 240 |
-
"google_client_id_configured": bool(settings.google_client_id),
|
| 241 |
-
"google_client_secret_configured": bool(settings.google_client_secret),
|
| 242 |
-
"configured_redirect_uri": settings.google_redirect_uri,
|
| 243 |
-
"auto_detected_redirect_uri": redirect_uri,
|
| 244 |
-
"configured_frontend_url": settings.frontend_url,
|
| 245 |
-
"auto_detected_frontend_url": frontend_url,
|
| 246 |
-
"request_headers": {
|
| 247 |
-
"host": request.headers.get("host"),
|
| 248 |
-
"x-forwarded-host": request.headers.get("x-forwarded-host"),
|
| 249 |
-
"x-forwarded-proto": request.headers.get("x-forwarded-proto"),
|
| 250 |
-
}
|
| 251 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/report_generator.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Report generation using OpenAI GPT directly (no ChatKit)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
from openai import AsyncOpenAI
|
| 9 |
+
|
| 10 |
+
from .config import get_settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports.
|
| 14 |
+
|
| 15 |
+
## Your Task
|
| 16 |
+
Generate a complete, self-contained HTML report based on the exam data and prompt provided.
|
| 17 |
+
|
| 18 |
+
## Language
|
| 19 |
+
- Use Traditional Chinese (繁體中文) for all content
|
| 20 |
+
- Include bilingual labels where appropriate (English + 中文)
|
| 21 |
+
|
| 22 |
+
## HTML Report Requirements
|
| 23 |
+
|
| 24 |
+
Generate a complete HTML file with these sections:
|
| 25 |
+
|
| 26 |
+
### Section 1: 📝 Q&A Analysis (題目詳解)
|
| 27 |
+
For each question:
|
| 28 |
+
- Question number, text, and type
|
| 29 |
+
- Correct answer highlighted
|
| 30 |
+
- Concept tags (e.g., 🎯 細節理解, 🔍 推論能力)
|
| 31 |
+
- Detailed explanation with: what it tests, solving strategy, common mistakes, learning points
|
| 32 |
+
|
| 33 |
+
### Section 2: 📊 Statistics (成績統計)
|
| 34 |
+
- Stats grid: total students, average, highest, lowest
|
| 35 |
+
- Score distribution bar chart (Chart.js)
|
| 36 |
+
- Question accuracy doughnut chart (Chart.js)
|
| 37 |
+
- Student details table with score badges (color-coded)
|
| 38 |
+
|
| 39 |
+
### Section 3: 👩🏫 Teacher Insights (教師分析與建議)
|
| 40 |
+
- Overall performance analysis
|
| 41 |
+
- Teaching recommendations
|
| 42 |
+
- Individual student support suggestions (🔴 needs attention, 🟡 needs reinforcement, 🟢 high performers)
|
| 43 |
+
|
| 44 |
+
## HTML Template Structure
|
| 45 |
+
|
| 46 |
+
Use this dark theme with the following CSS variables:
|
| 47 |
+
```
|
| 48 |
+
:root {
|
| 49 |
+
--bg-primary: #0f1419;
|
| 50 |
+
--bg-secondary: #1a2332;
|
| 51 |
+
--bg-card: #212d3b;
|
| 52 |
+
--accent-coral: #ff6b6b;
|
| 53 |
+
--accent-teal: #4ecdc4;
|
| 54 |
+
--accent-gold: #ffd93d;
|
| 55 |
+
--accent-purple: #a855f7;
|
| 56 |
+
--text-primary: #f1f5f9;
|
| 57 |
+
--text-secondary: #94a3b8;
|
| 58 |
+
--border-color: #334155;
|
| 59 |
+
}
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Include Chart.js via CDN: `<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>`
|
| 63 |
+
|
| 64 |
+
## Privacy
|
| 65 |
+
- Use partial names (e.g., 李X恩) in reports
|
| 66 |
+
- Never expose full student identifiers
|
| 67 |
+
- Group low performers sensitively
|
| 68 |
+
|
| 69 |
+
## Output
|
| 70 |
+
Return ONLY the complete HTML document. No markdown code fences. Just raw HTML starting with <!DOCTYPE html>."""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
DEFAULT_ANALYSIS_PROMPT = """請根據以下考試數據生成一份完整的分析報告。
|
| 74 |
+
|
| 75 |
+
## 考試題目:
|
| 76 |
+
{questions}
|
| 77 |
+
|
| 78 |
+
## 學生答案:
|
| 79 |
+
{student_answers}
|
| 80 |
+
|
| 81 |
+
## 標準答案:
|
| 82 |
+
{teacher_answers}
|
| 83 |
+
|
| 84 |
+
請生成包含以下內容的 HTML 報告:
|
| 85 |
+
1. 題目詳解 (Q&A Analysis) - 每題的詳細分析、解題策略、常見錯誤
|
| 86 |
+
2. 成績統計 (Statistics) - 包含 Chart.js 圖表的統計分析
|
| 87 |
+
3. 教師分析與建議 (Teacher Insights) - 教學改進建議和個別輔導建議
|
| 88 |
+
|
| 89 |
+
請自動計算每位學生的成績,識別常見錯誤模式,並提供具體的教學建議。"""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_default_prompt() -> str:
|
| 93 |
+
return DEFAULT_ANALYSIS_PROMPT
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def fill_prompt(template: str, questions: dict, student_answers: dict, teacher_answers: dict) -> str:
|
| 97 |
+
"""Fill the prompt template with actual data."""
|
| 98 |
+
return template.format(
|
| 99 |
+
questions=json.dumps(questions, ensure_ascii=False, indent=2),
|
| 100 |
+
student_answers=json.dumps(student_answers, ensure_ascii=False, indent=2),
|
| 101 |
+
teacher_answers=json.dumps(teacher_answers, ensure_ascii=False, indent=2),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def extract_html(content: str) -> str:
|
| 106 |
+
"""Extract HTML from potential markdown code blocks."""
|
| 107 |
+
# Try to find HTML in code blocks first
|
| 108 |
+
match = re.search(r"```html?\s*\n(.*?)```", content, re.DOTALL)
|
| 109 |
+
if match:
|
| 110 |
+
return match.group(1).strip()
|
| 111 |
+
# If content starts with <!DOCTYPE or <html, it's already raw HTML
|
| 112 |
+
stripped = content.strip()
|
| 113 |
+
if stripped.startswith("<!DOCTYPE") or stripped.startswith("<html"):
|
| 114 |
+
return stripped
|
| 115 |
+
return stripped
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
async def generate_report(
|
| 119 |
+
questions_data: dict,
|
| 120 |
+
student_answers_data: dict,
|
| 121 |
+
teacher_answers_data: dict,
|
| 122 |
+
prompt_template: str,
|
| 123 |
+
model: str = "gpt-5.4",
|
| 124 |
+
) -> str:
|
| 125 |
+
"""
|
| 126 |
+
Generate HTML report by sending parsed exam data + prompt to OpenAI.
|
| 127 |
+
Uses the same OPENAI_API_KEY as file parsing.
|
| 128 |
+
Returns complete HTML string.
|
| 129 |
+
"""
|
| 130 |
+
settings = get_settings()
|
| 131 |
+
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 132 |
+
|
| 133 |
+
filled_prompt = fill_prompt(prompt_template, questions_data, student_answers_data, teacher_answers_data)
|
| 134 |
+
|
| 135 |
+
kwargs: dict = {
|
| 136 |
+
"model": model,
|
| 137 |
+
"messages": [
|
| 138 |
+
{"role": "system", "content": REPORT_SYSTEM_PROMPT},
|
| 139 |
+
{"role": "user", "content": filled_prompt},
|
| 140 |
+
],
|
| 141 |
+
"max_completion_tokens": 16384,
|
| 142 |
+
"temperature": 0.3,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
response = await client.chat.completions.create(**kwargs)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
# Some models may not support max_completion_tokens; fallback
|
| 149 |
+
if "max_completion_tokens" in str(e):
|
| 150 |
+
kwargs.pop("max_completion_tokens")
|
| 151 |
+
kwargs["max_tokens"] = 16384
|
| 152 |
+
response = await client.chat.completions.create(**kwargs)
|
| 153 |
+
else:
|
| 154 |
+
raise
|
| 155 |
+
|
| 156 |
+
choice = response.choices[0]
|
| 157 |
+
html_content = choice.message.content
|
| 158 |
+
if not html_content:
|
| 159 |
+
reason = getattr(choice, "finish_reason", "unknown")
|
| 160 |
+
refusal = getattr(choice.message, "refusal", None)
|
| 161 |
+
detail = f"finish_reason={reason}"
|
| 162 |
+
if refusal:
|
| 163 |
+
detail += f", refusal={refusal}"
|
| 164 |
+
raise ValueError(f"Model returned empty content ({detail}). Try a different model or simplify your prompt.")
|
| 165 |
+
return extract_html(html_content)
|
chatkit/backend/app/server.py
DELETED
|
@@ -1,589 +0,0 @@
|
|
| 1 |
-
"""ClassLens ChatKit server with exam analysis agent and tools."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import json
|
| 6 |
-
from typing import Any, AsyncIterator
|
| 7 |
-
|
| 8 |
-
from agents import Runner, Agent, function_tool
|
| 9 |
-
from chatkit.agents import AgentContext, simple_to_agent_input, stream_agent_response
|
| 10 |
-
from chatkit.server import ChatKitServer
|
| 11 |
-
from chatkit.types import ThreadMetadata, ThreadStreamEvent, UserMessageItem
|
| 12 |
-
|
| 13 |
-
from .memory_store import MemoryStore
|
| 14 |
-
from .google_sheets import fetch_google_form_responses, parse_csv_responses, fetch_public_sheet, extract_sheet_id
|
| 15 |
-
from .email_service import send_email_report
|
| 16 |
-
from .database import save_report
|
| 17 |
-
from .status_tracker import update_status, add_reasoning_step, WorkflowStep, reset_status
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
MAX_RECENT_ITEMS = 50
|
| 21 |
-
DEFAULT_MODEL = "gpt-4.1-mini" # Default model for cost efficiency (~10x cheaper)
|
| 22 |
-
|
| 23 |
-
# Available models
|
| 24 |
-
AVAILABLE_MODELS = {
|
| 25 |
-
"gpt-4.1-mini": {
|
| 26 |
-
"name": "GPT-4.1-mini",
|
| 27 |
-
"description": "快速且經濟實惠(默認)",
|
| 28 |
-
"cost": "低",
|
| 29 |
-
},
|
| 30 |
-
"gpt-4o": {
|
| 31 |
-
"name": "GPT-4o",
|
| 32 |
-
"description": "高性能,適合複雜分析",
|
| 33 |
-
"cost": "中",
|
| 34 |
-
},
|
| 35 |
-
"gpt-4o-mini": {
|
| 36 |
-
"name": "GPT-4o-mini",
|
| 37 |
-
"description": "平衡性能與成本",
|
| 38 |
-
"cost": "低",
|
| 39 |
-
},
|
| 40 |
-
"gpt-5-pro": {
|
| 41 |
-
"name": "GPT-5 Pro",
|
| 42 |
-
"description": "最新旗艦模型,具備強大推理能力",
|
| 43 |
-
"cost": "高",
|
| 44 |
-
},
|
| 45 |
-
"o3-pro": {
|
| 46 |
-
"name": "O3 Pro",
|
| 47 |
-
"description": "深度推理模型,適合複雜問題分析",
|
| 48 |
-
"cost": "高",
|
| 49 |
-
},
|
| 50 |
-
"o1-preview": {
|
| 51 |
-
"name": "O1 Preview",
|
| 52 |
-
"description": "具備深度推理能力",
|
| 53 |
-
"cost": "高",
|
| 54 |
-
},
|
| 55 |
-
"o1-mini": {
|
| 56 |
-
"name": "O1 Mini",
|
| 57 |
-
"description": "具備推理能力,經濟實惠",
|
| 58 |
-
"cost": "中",
|
| 59 |
-
},
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
# Current session ID (simplified - in production use proper session management)
|
| 63 |
-
_current_session_id = "default"
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def set_session_id(session_id: str):
|
| 67 |
-
global _current_session_id
|
| 68 |
-
_current_session_id = session_id
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def get_model_from_context(context: dict[str, Any]) -> str:
|
| 72 |
-
"""Get model from context, default to DEFAULT_MODEL if not specified."""
|
| 73 |
-
# Try multiple ways to get model from context
|
| 74 |
-
model = (
|
| 75 |
-
context.get("model") or
|
| 76 |
-
context.get("request", {}).get("model") or
|
| 77 |
-
# Check query params
|
| 78 |
-
(context.get("request", {}).get("query_params", {}).get("model") if hasattr(context.get("request", {}), "query_params") else None) or
|
| 79 |
-
# Check headers
|
| 80 |
-
(context.get("request", {}).headers.get("x-model") if hasattr(context.get("request", {}), "headers") else None)
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
if model and model in AVAILABLE_MODELS:
|
| 84 |
-
return model
|
| 85 |
-
return DEFAULT_MODEL
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def create_agent(model: str) -> Agent[AgentContext[dict[str, Any]]]:
|
| 89 |
-
"""Create an agent with the specified model."""
|
| 90 |
-
return Agent[AgentContext[dict[str, Any]]](
|
| 91 |
-
model=model,
|
| 92 |
-
name="ClassLens",
|
| 93 |
-
instructions=EXAMINSIGHT_INSTRUCTIONS,
|
| 94 |
-
tools=[
|
| 95 |
-
fetch_responses,
|
| 96 |
-
parse_csv_data,
|
| 97 |
-
send_report_email,
|
| 98 |
-
save_analysis_report,
|
| 99 |
-
log_reasoning,
|
| 100 |
-
],
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
# =============================================================================
|
| 105 |
-
# Tool Definitions with Status Tracking
|
| 106 |
-
# =============================================================================
|
| 107 |
-
|
| 108 |
-
@function_tool
|
| 109 |
-
async def fetch_responses(
|
| 110 |
-
google_form_url: str,
|
| 111 |
-
teacher_email: str = "",
|
| 112 |
-
answer_key_json: str = ""
|
| 113 |
-
) -> str:
|
| 114 |
-
"""
|
| 115 |
-
Fetch student responses from a Google Form/Sheets URL.
|
| 116 |
-
First tries to fetch as a public sheet (no auth needed).
|
| 117 |
-
If that fails and teacher_email is provided, tries OAuth.
|
| 118 |
-
|
| 119 |
-
Args:
|
| 120 |
-
google_form_url: The URL of the Google Form response spreadsheet
|
| 121 |
-
teacher_email: The teacher's email address for authentication (optional for public sheets)
|
| 122 |
-
answer_key_json: Optional JSON string with correct answers, e.g. {"Q1": "4", "Q2": "acceleration"}
|
| 123 |
-
|
| 124 |
-
Returns:
|
| 125 |
-
JSON string with normalized exam data including questions and student responses
|
| 126 |
-
"""
|
| 127 |
-
# Log tool call
|
| 128 |
-
await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: fetch_responses(url={google_form_url[:50]}...)", "active")
|
| 129 |
-
|
| 130 |
-
answer_key = None
|
| 131 |
-
if answer_key_json:
|
| 132 |
-
try:
|
| 133 |
-
answer_key = json.loads(answer_key_json)
|
| 134 |
-
except json.JSONDecodeError:
|
| 135 |
-
pass
|
| 136 |
-
|
| 137 |
-
# First, try to fetch as a public sheet (no OAuth needed)
|
| 138 |
-
sheet_id = extract_sheet_id(google_form_url)
|
| 139 |
-
if sheet_id:
|
| 140 |
-
await add_reasoning_step(_current_session_id, "tool", "📥 Downloading spreadsheet data...", "active")
|
| 141 |
-
public_result = await fetch_public_sheet(sheet_id, answer_key)
|
| 142 |
-
if "error" not in public_result:
|
| 143 |
-
await add_reasoning_step(_current_session_id, "result", "✅ Data fetched successfully", "completed")
|
| 144 |
-
return json.dumps(public_result, indent=2)
|
| 145 |
-
|
| 146 |
-
# If public fetch failed and we have teacher email, try OAuth
|
| 147 |
-
if teacher_email:
|
| 148 |
-
await add_reasoning_step(_current_session_id, "tool", "🔐 Using OAuth to access private sheet...", "active")
|
| 149 |
-
result = await fetch_google_form_responses(google_form_url, teacher_email, answer_key)
|
| 150 |
-
if "error" not in result:
|
| 151 |
-
await add_reasoning_step(_current_session_id, "result", "✅ Data fetched via OAuth", "completed")
|
| 152 |
-
return json.dumps(result, indent=2)
|
| 153 |
-
else:
|
| 154 |
-
# Return the public sheet error with helpful message
|
| 155 |
-
public_result["hint"] = "To access private sheets, provide your email and connect your Google account."
|
| 156 |
-
await add_reasoning_step(_current_session_id, "error", "❌ Could not access sheet", "completed")
|
| 157 |
-
return json.dumps(public_result, indent=2)
|
| 158 |
-
|
| 159 |
-
await add_reasoning_step(_current_session_id, "error", "❌ Invalid URL format", "completed")
|
| 160 |
-
return json.dumps({
|
| 161 |
-
"error": "Could not extract sheet ID from URL. Please provide a valid Google Sheets URL.",
|
| 162 |
-
"hint": "URL should look like: https://docs.google.com/spreadsheets/d/SHEET_ID/edit"
|
| 163 |
-
}, indent=2)
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
@function_tool
|
| 167 |
-
async def parse_csv_data(
|
| 168 |
-
csv_content: str,
|
| 169 |
-
answer_key_json: str = ""
|
| 170 |
-
) -> str:
|
| 171 |
-
"""
|
| 172 |
-
Parse CSV content directly (for manual upload fallback).
|
| 173 |
-
|
| 174 |
-
Args:
|
| 175 |
-
csv_content: The raw CSV content with headers and student responses
|
| 176 |
-
answer_key_json: Optional JSON string with correct answers
|
| 177 |
-
|
| 178 |
-
Returns:
|
| 179 |
-
JSON string with normalized exam data
|
| 180 |
-
"""
|
| 181 |
-
await add_reasoning_step(_current_session_id, "tool", "🔧 Tool: parse_csv_data()", "active")
|
| 182 |
-
|
| 183 |
-
answer_key = None
|
| 184 |
-
if answer_key_json:
|
| 185 |
-
try:
|
| 186 |
-
answer_key = json.loads(answer_key_json)
|
| 187 |
-
except json.JSONDecodeError:
|
| 188 |
-
pass
|
| 189 |
-
|
| 190 |
-
result = parse_csv_responses(csv_content, answer_key)
|
| 191 |
-
|
| 192 |
-
await add_reasoning_step(_current_session_id, "result", "✅ CSV parsed successfully", "completed")
|
| 193 |
-
|
| 194 |
-
return json.dumps(result, indent=2)
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
@function_tool
|
| 198 |
-
async def send_report_email(
|
| 199 |
-
email: str,
|
| 200 |
-
subject: str,
|
| 201 |
-
body_markdown: str
|
| 202 |
-
) -> str:
|
| 203 |
-
"""
|
| 204 |
-
Send the exam analysis report to the teacher via email.
|
| 205 |
-
|
| 206 |
-
Args:
|
| 207 |
-
email: The teacher's email address
|
| 208 |
-
subject: Email subject line
|
| 209 |
-
body_markdown: The full report in markdown format
|
| 210 |
-
|
| 211 |
-
Returns:
|
| 212 |
-
JSON string with status of the email send operation
|
| 213 |
-
"""
|
| 214 |
-
await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: send_report_email(to={email})", "active")
|
| 215 |
-
|
| 216 |
-
result = await send_email_report(email, subject, body_markdown)
|
| 217 |
-
|
| 218 |
-
if result.get("status") == "ok":
|
| 219 |
-
await add_reasoning_step(_current_session_id, "result", "✅ Email sent successfully!", "completed")
|
| 220 |
-
else:
|
| 221 |
-
await add_reasoning_step(_current_session_id, "error", f"❌ Email failed: {result.get('message', 'unknown error')}", "completed")
|
| 222 |
-
|
| 223 |
-
return json.dumps(result)
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
@function_tool
|
| 227 |
-
async def save_analysis_report(
|
| 228 |
-
teacher_email: str,
|
| 229 |
-
exam_title: str,
|
| 230 |
-
report_markdown: str,
|
| 231 |
-
report_json: str
|
| 232 |
-
) -> str:
|
| 233 |
-
"""
|
| 234 |
-
Save the analysis report to the database for future reference.
|
| 235 |
-
|
| 236 |
-
Args:
|
| 237 |
-
teacher_email: The teacher's email address
|
| 238 |
-
exam_title: Title of the exam
|
| 239 |
-
report_markdown: The report in markdown format
|
| 240 |
-
report_json: The structured report data in JSON format
|
| 241 |
-
|
| 242 |
-
Returns:
|
| 243 |
-
Confirmation message
|
| 244 |
-
"""
|
| 245 |
-
await add_reasoning_step(_current_session_id, "tool", f"🔧 Tool: save_analysis_report(exam={exam_title})", "active")
|
| 246 |
-
await add_reasoning_step(_current_session_id, "result", "✅ Report saved to database", "completed")
|
| 247 |
-
|
| 248 |
-
await save_report(teacher_email, exam_title, report_markdown, report_json)
|
| 249 |
-
return json.dumps({"status": "saved", "message": "Report saved successfully"})
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
@function_tool
|
| 253 |
-
async def log_reasoning(thought: str) -> str:
|
| 254 |
-
"""
|
| 255 |
-
Log your current thinking or reasoning step.
|
| 256 |
-
Use this to show the user what you're analyzing or planning.
|
| 257 |
-
|
| 258 |
-
Args:
|
| 259 |
-
thought: Your current thought, reasoning, or task breakdown
|
| 260 |
-
|
| 261 |
-
Returns:
|
| 262 |
-
Confirmation
|
| 263 |
-
"""
|
| 264 |
-
await add_reasoning_step(_current_session_id, "thinking", thought, "completed")
|
| 265 |
-
return json.dumps({"status": "logged"})
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
# =============================================================================
|
| 269 |
-
# ClassLens Agent Definition
|
| 270 |
-
# =============================================================================
|
| 271 |
-
|
| 272 |
-
EXAMINSIGHT_INSTRUCTIONS = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports for teachers.
|
| 273 |
-
|
| 274 |
-
## Language & Communication
|
| 275 |
-
- Always communicate in Traditional Chinese (繁體中文, zh-TW)
|
| 276 |
-
- Use polite and professional language
|
| 277 |
-
- When greeting users, say: "您好!我是 ClassLens 助手,今天能為您���些什麼?"
|
| 278 |
-
- All responses, explanations, and reports should be in Traditional Chinese unless the user specifically requests English
|
| 279 |
-
|
| 280 |
-
## Your Core Mission
|
| 281 |
-
|
| 282 |
-
Transform raw Google Form quiz responses into professional, teacher-ready HTML reports with:
|
| 283 |
-
1. Detailed question analysis with bilingual explanations (English + 中文)
|
| 284 |
-
2. Visual statistics with Chart.js charts
|
| 285 |
-
3. Actionable teaching recommendations
|
| 286 |
-
4. Individual student support suggestions
|
| 287 |
-
|
| 288 |
-
## IMPORTANT: Show Your Reasoning
|
| 289 |
-
|
| 290 |
-
Use `log_reasoning` to show your thinking process. Call it at key decision points:
|
| 291 |
-
- `log_reasoning("Task: Analyze 12 students × 5 questions, generate HTML report")`
|
| 292 |
-
- `log_reasoning("Grading Q1: 5 correct (42%), Q2: 10 correct (83%)")`
|
| 293 |
-
- `log_reasoning("Pattern: Many students confused 'is' vs 'wants to be'")`
|
| 294 |
-
|
| 295 |
-
## Workflow
|
| 296 |
-
|
| 297 |
-
### Step 1: Fetch & Analyze Data
|
| 298 |
-
Use `fetch_responses` with the Google Form URL.
|
| 299 |
-
Log observations: `log_reasoning("Found 12 students, 5 questions. Q1-Q4 multiple choice, Q5 writing.")`
|
| 300 |
-
|
| 301 |
-
### Step 2: Grade & Calculate Statistics
|
| 302 |
-
- Calculate per-question accuracy rates
|
| 303 |
-
- Compute class average, highest, lowest scores
|
| 304 |
-
- Identify score distribution bands
|
| 305 |
-
Log: `log_reasoning("Stats: Avg=20.8/70, Q1=42%, Q2=83%, Q3=50%, Q4=83%")`
|
| 306 |
-
|
| 307 |
-
### Step 3: Identify Patterns & Misconceptions
|
| 308 |
-
Analyze common mistakes and their root causes.
|
| 309 |
-
Log: `log_reasoning("Misconception: Students confused Bella (IS teacher) with Eddie (WANTS TO BE teacher)")`
|
| 310 |
-
|
| 311 |
-
### Step 4: Generate HTML Report
|
| 312 |
-
|
| 313 |
-
**CRITICAL: Generate a complete, self-contained HTML file** that includes:
|
| 314 |
-
|
| 315 |
-
#### Required HTML Structure:
|
| 316 |
-
|
| 317 |
-
```html
|
| 318 |
-
<!DOCTYPE html>
|
| 319 |
-
<html lang="zh-TW">
|
| 320 |
-
<head>
|
| 321 |
-
<meta charset="UTF-8">
|
| 322 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 323 |
-
<title>[Quiz Title] Report</title>
|
| 324 |
-
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 325 |
-
<style>
|
| 326 |
-
/* Dark theme with coral/teal accents */
|
| 327 |
-
:root {
|
| 328 |
-
--bg-primary: #0f1419;
|
| 329 |
-
--bg-secondary: #1a2332;
|
| 330 |
-
--bg-card: #212d3b;
|
| 331 |
-
--accent-coral: #ff6b6b;
|
| 332 |
-
--accent-teal: #4ecdc4;
|
| 333 |
-
--accent-gold: #ffd93d;
|
| 334 |
-
--accent-purple: #a855f7;
|
| 335 |
-
--text-primary: #f1f5f9;
|
| 336 |
-
--text-secondary: #94a3b8;
|
| 337 |
-
--border-color: #334155;
|
| 338 |
-
}
|
| 339 |
-
/* Include comprehensive CSS for all elements */
|
| 340 |
-
</style>
|
| 341 |
-
</head>
|
| 342 |
-
<body>
|
| 343 |
-
<!-- Header with quiz title and Google Form link -->
|
| 344 |
-
<!-- Navigation tabs: Q&A, Statistics, Teacher Insights -->
|
| 345 |
-
<!-- Main content sections -->
|
| 346 |
-
<!-- Chart.js scripts -->
|
| 347 |
-
</body>
|
| 348 |
-
</html>
|
| 349 |
-
```
|
| 350 |
-
|
| 351 |
-
#### Section 1: 📝 Q&A Analysis (題目詳解)
|
| 352 |
-
|
| 353 |
-
For EACH question, include:
|
| 354 |
-
1. **Question number badge** (gradient circle)
|
| 355 |
-
2. **Question text** (bilingual: English + Chinese)
|
| 356 |
-
3. **Answer box** with correct answer highlighted
|
| 357 |
-
4. **Concept tags** showing skills tested (e.g., 🎯 細節理解, 🔍 推論能力)
|
| 358 |
-
5. **Detailed explanation** (詳解) with:
|
| 359 |
-
- What the question tests (這題測試什麼?)
|
| 360 |
-
- Key solving strategy (解題關鍵)
|
| 361 |
-
- Common mistakes (常見錯誤)
|
| 362 |
-
- Learning points (學習重點)
|
| 363 |
-
|
| 364 |
-
If there's a reading passage, include it with `<span class="highlight">` for key terms.
|
| 365 |
-
|
| 366 |
-
#### Section 2: 📊 Statistics (成績統計)
|
| 367 |
-
|
| 368 |
-
Include:
|
| 369 |
-
1. **Stats grid**: Total students, Average, Highest, Lowest
|
| 370 |
-
2. **Score distribution bar chart** (Chart.js)
|
| 371 |
-
3. **Question accuracy doughnut chart** (Chart.js)
|
| 372 |
-
4. **Student details table** with columns:
|
| 373 |
-
- Name (姓名), Class (班級), Score, Q1-Qn status (✅/❌)
|
| 374 |
-
- Color-coded score badges: high (teal), mid (gold), low (coral)
|
| 375 |
-
|
| 376 |
-
#### Section 3: 👩🏫 Teacher Insights (教師分析與建議)
|
| 377 |
-
|
| 378 |
-
Include:
|
| 379 |
-
1. **Overall Performance Analysis** (整體表現分析)
|
| 380 |
-
- Summary paragraph
|
| 381 |
-
- Per-question breakdown with accuracy % and issues identified
|
| 382 |
-
|
| 383 |
-
2. **Teaching Recommendations** (教學改進建議)
|
| 384 |
-
- Specific, actionable suggestions based on data
|
| 385 |
-
- Priority areas to address
|
| 386 |
-
|
| 387 |
-
3. **Next Exam Prompt** (下次出題 Prompt 建議)
|
| 388 |
-
- Ready-to-use AI prompt for generating the next quiz
|
| 389 |
-
- Include specific areas to reinforce based on this quiz's results
|
| 390 |
-
|
| 391 |
-
4. **Individual Support** (個別輔導建議)
|
| 392 |
-
- 🔴 Students needing attention (0 or very low scores)
|
| 393 |
-
- 🟡 Students needing reinforcement
|
| 394 |
-
- 🟢 High performers (potential peer tutors)
|
| 395 |
-
|
| 396 |
-
#### Chart.js Implementation
|
| 397 |
-
|
| 398 |
-
Include these chart configurations:
|
| 399 |
-
```javascript
|
| 400 |
-
// Score Distribution Chart
|
| 401 |
-
new Chart(document.getElementById('scoreChart').getContext('2d'), {
|
| 402 |
-
type: 'bar',
|
| 403 |
-
data: {
|
| 404 |
-
labels: ['0分', '10分', '20分', '30分', '40分+'],
|
| 405 |
-
datasets: [{
|
| 406 |
-
data: [/* distribution counts */],
|
| 407 |
-
backgroundColor: ['rgba(255,107,107,0.7)', ...],
|
| 408 |
-
borderRadius: 8
|
| 409 |
-
}]
|
| 410 |
-
},
|
| 411 |
-
options: { /* dark theme styling */ }
|
| 412 |
-
});
|
| 413 |
-
|
| 414 |
-
// Question Accuracy Chart
|
| 415 |
-
new Chart(document.getElementById('questionChart').getContext('2d'), {
|
| 416 |
-
type: 'doughnut',
|
| 417 |
-
data: {
|
| 418 |
-
labels: ['Q1 (XX%)', 'Q2 (XX%)', ...],
|
| 419 |
-
datasets: [{ data: [/* accuracy rates */] }]
|
| 420 |
-
}
|
| 421 |
-
});
|
| 422 |
-
```
|
| 423 |
-
|
| 424 |
-
### Step 5: Summary and Email Report
|
| 425 |
-
|
| 426 |
-
**CRITICAL: After analyzing the exam data, follow these steps:**
|
| 427 |
-
|
| 428 |
-
1. **Display a bullet-point summary** in chat (in Traditional Chinese):
|
| 429 |
-
- 總學生數、平均分數、最高分、最低分
|
| 430 |
-
- 各題正確率(例如:Q1: 85%, Q2: 60%, Q3: 90%)
|
| 431 |
-
- 主要發現(例如:多數學生在 Q2 答錯,可能對某概念理解不足)
|
| 432 |
-
- 需要關注的學生(低分學生)
|
| 433 |
-
- 表現優秀的學生(可作為同儕導師)
|
| 434 |
-
|
| 435 |
-
Format example:
|
| 436 |
-
```
|
| 437 |
-
📊 考試分析摘要:
|
| 438 |
-
|
| 439 |
-
• 總學生數:25 人
|
| 440 |
-
• 平均分數:72 分(滿分 100)
|
| 441 |
-
• 最高分:95 分,最低分:45 分
|
| 442 |
-
|
| 443 |
-
📈 各題正確率:
|
| 444 |
-
• Q1:85% 正確
|
| 445 |
-
• Q2:60% 正確 ⚠️(需要加強)
|
| 446 |
-
• Q3:90% 正確
|
| 447 |
-
|
| 448 |
-
🔍 主要發現:
|
| 449 |
-
• 多數學生在 Q2 答錯,可能對「加速度」概念理解不足
|
| 450 |
-
• 約 30% 學生在開放式問題中表達不清楚
|
| 451 |
-
|
| 452 |
-
👥 需要關注的學生:3 人(分數低於 50 分)
|
| 453 |
-
⭐ 表現優秀的學生:5 人(分數高於 90 分,可作為同儕導師)
|
| 454 |
-
```
|
| 455 |
-
|
| 456 |
-
2. **Ask for confirmation**: After showing the summary, ask in Traditional Chinese:
|
| 457 |
-
"以上是分析摘要。您希望我生成完整的 HTML 詳細報告並發送到您的電子郵件嗎?"
|
| 458 |
-
|
| 459 |
-
3. **Wait for user confirmation** before generating and sending the HTML report
|
| 460 |
-
- Only proceed when the teacher explicitly confirms (says "yes", "send", "發送", "好的", "是", "要", "生成", "寄給我", etc.)
|
| 461 |
-
|
| 462 |
-
4. **After confirmation**:
|
| 463 |
-
- Generate the complete HTML report with all sections (Q&A Analysis, Statistics with charts, Teacher Insights)
|
| 464 |
-
- Create an appropriate email subject line (e.g., "考試分析報告 - [Quiz Title] - [Date]" or "ClassLens 考試分析報告")
|
| 465 |
-
- Call `send_report_email` with:
|
| 466 |
-
* email: teacher's email address
|
| 467 |
-
* subject: the email subject line you created
|
| 468 |
-
* body_markdown: the complete HTML report content
|
| 469 |
-
- After successfully sending, confirm with: "報告已生成並發送到您的電子郵件「[subject]」。報告包含詳細的題目分析、統計圖表和教學建議。請檢查您的收件匣。"
|
| 470 |
-
- Make sure to include the actual subject line in the confirmation message (replace [subject] with the actual subject you used)
|
| 471 |
-
|
| 472 |
-
## Output Format
|
| 473 |
-
|
| 474 |
-
When analyzing exam data:
|
| 475 |
-
1. First display a bullet-point summary in chat (in Traditional Chinese)
|
| 476 |
-
2. Ask: "以上是分析摘要。您希望我生成完整的 HTML 詳細報告並發送到您的電子郵件嗎?"
|
| 477 |
-
3. Wait for user confirmation
|
| 478 |
-
4. Only after confirmation: Generate complete HTML report and send via email
|
| 479 |
-
5. Do NOT automatically generate or send the HTML report - always show summary first and ask for confirmation
|
| 480 |
-
|
| 481 |
-
## Design Principles
|
| 482 |
-
|
| 483 |
-
- **Dark theme** with coral (#ff6b6b) and teal (#4ecdc4) accents
|
| 484 |
-
- **Bilingual** content (English + Traditional Chinese)
|
| 485 |
-
- **Responsive** layout for mobile viewing
|
| 486 |
-
- **Smooth scrolling** navigation
|
| 487 |
-
- **Hover effects** on cards and tables
|
| 488 |
-
- **Gradient accents** for visual interest
|
| 489 |
-
|
| 490 |
-
## Handling Edge Cases
|
| 491 |
-
|
| 492 |
-
- No answer key: Infer patterns or ask teacher for correct answers
|
| 493 |
-
- Private sheet: Guide teacher through OAuth connection
|
| 494 |
-
- Writing questions: Provide rubric and sample excellent responses
|
| 495 |
-
- Few students: Adjust charts to prevent visual distortion
|
| 496 |
-
|
| 497 |
-
## Privacy
|
| 498 |
-
|
| 499 |
-
- Use partial names (e.g., 李X恩) in reports
|
| 500 |
-
- Never expose full student identifiers
|
| 501 |
-
- Group low performers sensitively
|
| 502 |
-
|
| 503 |
-
## Initial Conversation Flow
|
| 504 |
-
|
| 505 |
-
When starting a new conversation, follow this sequence:
|
| 506 |
-
|
| 507 |
-
1. **Greet the teacher** in Traditional Chinese: "您好!我是 ClassLens 助手,今天能為您做些什麼?"
|
| 508 |
-
|
| 509 |
-
2. **Ask for Google Form/Sheet URL**: "請提供您的 Google 表單或試算表網址。"
|
| 510 |
-
|
| 511 |
-
3. **Ask about answer key** (標準答案):
|
| 512 |
-
- "請問您是否方便提供本次考試的正確答案(標準答案)?"
|
| 513 |
-
- "如果您有標準答案,請提供給我,這樣我可以更準確地評分和分析。"
|
| 514 |
-
- "如果您沒有標準答案,我可以嘗試根據學生的回答模式自動推斷標準答案。您希望我為您自動生成標準答案嗎?"
|
| 515 |
-
|
| 516 |
-
4. **Ask for email**: "請提供您的電子郵件地址,以便我將分析報告發送給您。"
|
| 517 |
-
|
| 518 |
-
5. **Wait for all information** before starting analysis:
|
| 519 |
-
- Google Form/Sheet URL (required)
|
| 520 |
-
- Answer key (optional, but recommended for accuracy)
|
| 521 |
-
- Teacher email (required for sending report)
|
| 522 |
-
|
| 523 |
-
## Answer Key Handling
|
| 524 |
-
|
| 525 |
-
- **If teacher provides answer key**: Use it directly for accurate grading
|
| 526 |
-
- **If teacher doesn't have answer key but wants auto-generation**:
|
| 527 |
-
- Analyze student responses to infer the most likely correct answers
|
| 528 |
-
- Show the inferred answers to the teacher for confirmation
|
| 529 |
-
- Ask: "根據學生回答模式,我推斷的標準答案如下:[列出答案]。請確認這些答案是否正確,或提��修正。"
|
| 530 |
-
- **If teacher doesn't provide and doesn't want auto-generation**:
|
| 531 |
-
- Proceed with analysis but note that grading accuracy may be limited
|
| 532 |
-
- Focus on response patterns and common mistakes rather than absolute correctness"""
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
# Default agent (will be overridden by dynamic agent creation in respond method)
|
| 536 |
-
classlens_agent = create_agent(DEFAULT_MODEL)
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
# =============================================================================
|
| 540 |
-
# ChatKit Server Implementation
|
| 541 |
-
# =============================================================================
|
| 542 |
-
|
| 543 |
-
class ClassLensChatServer(ChatKitServer[dict[str, Any]]):
|
| 544 |
-
"""Server implementation for ClassLens exam analysis."""
|
| 545 |
-
|
| 546 |
-
def __init__(self) -> None:
|
| 547 |
-
self.store: MemoryStore = MemoryStore()
|
| 548 |
-
super().__init__(self.store)
|
| 549 |
-
|
| 550 |
-
async def respond(
|
| 551 |
-
self,
|
| 552 |
-
thread: ThreadMetadata,
|
| 553 |
-
item: UserMessageItem | None,
|
| 554 |
-
context: dict[str, Any],
|
| 555 |
-
) -> AsyncIterator[ThreadStreamEvent]:
|
| 556 |
-
# Reset status for new analysis
|
| 557 |
-
reset_status(thread.id)
|
| 558 |
-
set_session_id(thread.id)
|
| 559 |
-
|
| 560 |
-
# Get model from context (user selection from frontend)
|
| 561 |
-
selected_model = get_model_from_context(context)
|
| 562 |
-
|
| 563 |
-
# Create agent with selected model
|
| 564 |
-
agent = create_agent(selected_model)
|
| 565 |
-
|
| 566 |
-
items_page = await self.store.load_thread_items(
|
| 567 |
-
thread.id,
|
| 568 |
-
after=None,
|
| 569 |
-
limit=MAX_RECENT_ITEMS,
|
| 570 |
-
order="desc",
|
| 571 |
-
context=context,
|
| 572 |
-
)
|
| 573 |
-
items = list(reversed(items_page.data))
|
| 574 |
-
agent_input = await simple_to_agent_input(items)
|
| 575 |
-
|
| 576 |
-
agent_context = AgentContext(
|
| 577 |
-
thread=thread,
|
| 578 |
-
store=self.store,
|
| 579 |
-
request_context=context,
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
result = Runner.run_streamed(
|
| 583 |
-
agent,
|
| 584 |
-
agent_input,
|
| 585 |
-
context=agent_context,
|
| 586 |
-
)
|
| 587 |
-
|
| 588 |
-
async for event in stream_agent_response(agent_context, result):
|
| 589 |
-
yield event
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/status_tracker.py
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 1 |
-
"""Real-time status tracking for AI agent reasoning."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import asyncio
|
| 6 |
-
from datetime import datetime
|
| 7 |
-
from typing import Optional
|
| 8 |
-
from collections import defaultdict
|
| 9 |
-
|
| 10 |
-
# Global status store
|
| 11 |
-
_status_store: dict[str, dict] = defaultdict(lambda: {
|
| 12 |
-
"current_step": None,
|
| 13 |
-
"steps": [],
|
| 14 |
-
"started_at": None,
|
| 15 |
-
"completed_at": None,
|
| 16 |
-
})
|
| 17 |
-
|
| 18 |
-
_subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class WorkflowStep:
|
| 22 |
-
FETCH = "fetch"
|
| 23 |
-
NORMALIZE = "normalize"
|
| 24 |
-
GRADE = "grade"
|
| 25 |
-
EXPLAIN = "explain"
|
| 26 |
-
GROUP = "group"
|
| 27 |
-
REPORT = "report"
|
| 28 |
-
EMAIL = "email"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
async def add_reasoning_step(session_id: str, step_type: str, content: str, status: str = "completed"):
|
| 32 |
-
"""Add a reasoning step to show what the AI is doing."""
|
| 33 |
-
store = _status_store[session_id]
|
| 34 |
-
|
| 35 |
-
if store["started_at"] is None:
|
| 36 |
-
store["started_at"] = datetime.utcnow().isoformat()
|
| 37 |
-
|
| 38 |
-
# Mark any active steps as completed
|
| 39 |
-
for s in store["steps"]:
|
| 40 |
-
if s.get("status") == "active":
|
| 41 |
-
s["status"] = "completed"
|
| 42 |
-
|
| 43 |
-
step_data = {
|
| 44 |
-
"type": step_type, # "tool", "result", "error", "thinking"
|
| 45 |
-
"content": content,
|
| 46 |
-
"status": status,
|
| 47 |
-
"timestamp": datetime.utcnow().isoformat(),
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
store["steps"].append(step_data)
|
| 51 |
-
store["current_step"] = step_type if status == "active" else None
|
| 52 |
-
|
| 53 |
-
# Emit with "reasoning" key for frontend
|
| 54 |
-
emit_data = {
|
| 55 |
-
"reasoning": store["steps"],
|
| 56 |
-
"started_at": store["started_at"],
|
| 57 |
-
"completed_at": store["completed_at"],
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
await notify_subscribers(session_id, emit_data)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
async def update_status(session_id: str, step: str, status: str = "active", detail: str = ""):
|
| 64 |
-
"""Update the current workflow status (legacy support)."""
|
| 65 |
-
await add_reasoning_step(session_id, step, detail, status)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
async def notify_subscribers(session_id: str, status: dict):
|
| 69 |
-
"""Notify all subscribers of status change."""
|
| 70 |
-
for queue in _subscribers[session_id]:
|
| 71 |
-
try:
|
| 72 |
-
await queue.put(status.copy())
|
| 73 |
-
except:
|
| 74 |
-
pass
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def get_status(session_id: str) -> dict:
|
| 78 |
-
"""Get current status for a session."""
|
| 79 |
-
return dict(_status_store[session_id])
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def reset_status(session_id: str):
|
| 83 |
-
"""Reset status for a new analysis."""
|
| 84 |
-
_status_store[session_id] = {
|
| 85 |
-
"current_step": None,
|
| 86 |
-
"steps": [],
|
| 87 |
-
"started_at": None,
|
| 88 |
-
"completed_at": None,
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
async def subscribe(session_id: str) -> asyncio.Queue:
|
| 93 |
-
"""Subscribe to status updates for a session."""
|
| 94 |
-
queue = asyncio.Queue()
|
| 95 |
-
_subscribers[session_id].append(queue)
|
| 96 |
-
return queue
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def unsubscribe(session_id: str, queue: asyncio.Queue):
|
| 100 |
-
"""Unsubscribe from status updates."""
|
| 101 |
-
if queue in _subscribers[session_id]:
|
| 102 |
-
_subscribers[session_id].remove(queue)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/pyproject.toml
CHANGED
|
@@ -1,23 +1,21 @@
|
|
| 1 |
[project]
|
| 2 |
-
name = "
|
| 3 |
-
version = "0.
|
| 4 |
-
description = "
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
dependencies = [
|
| 7 |
"fastapi>=0.114,<0.116",
|
| 8 |
"uvicorn[standard]>=0.36,<0.37",
|
| 9 |
"openai>=1.40",
|
| 10 |
-
"
|
| 11 |
-
"
|
| 12 |
-
"
|
| 13 |
-
"google-api-python-client>=2.100.0",
|
| 14 |
-
"sendgrid>=6.10.0",
|
| 15 |
"python-dotenv>=1.0.0",
|
| 16 |
"aiosqlite>=0.19.0",
|
| 17 |
"cryptography>=41.0.0",
|
| 18 |
"pydantic>=2.0.0",
|
| 19 |
"httpx>=0.25.0",
|
| 20 |
-
"
|
| 21 |
]
|
| 22 |
|
| 23 |
[project.optional-dependencies]
|
|
|
|
| 1 |
[project]
|
| 2 |
+
name = "classlens-backend"
|
| 3 |
+
version = "2.0.0"
|
| 4 |
+
description = "ClassLens: AI-powered exam analysis for teachers"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
dependencies = [
|
| 7 |
"fastapi>=0.114,<0.116",
|
| 8 |
"uvicorn[standard]>=0.36,<0.37",
|
| 9 |
"openai>=1.40",
|
| 10 |
+
"python-jose[cryptography]>=3.3.0",
|
| 11 |
+
"passlib[bcrypt]>=1.7.4",
|
| 12 |
+
"python-multipart>=0.0.6",
|
|
|
|
|
|
|
| 13 |
"python-dotenv>=1.0.0",
|
| 14 |
"aiosqlite>=0.19.0",
|
| 15 |
"cryptography>=41.0.0",
|
| 16 |
"pydantic>=2.0.0",
|
| 17 |
"httpx>=0.25.0",
|
| 18 |
+
"pymupdf>=1.23.0",
|
| 19 |
]
|
| 20 |
|
| 21 |
[project.optional-dependencies]
|
chatkit/frontend/index.html
CHANGED
|
@@ -4,12 +4,11 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>ClassLens — AI-Powered Exam Analysis for Teachers</title>
|
| 7 |
-
<meta name="description" content="
|
| 8 |
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
|
| 12 |
-
<script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"></script>
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>ClassLens — AI-Powered Exam Analysis for Teachers</title>
|
| 7 |
+
<meta name="description" content="Upload exam files, parse Q&A data, and generate AI-powered analysis reports." />
|
| 8 |
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
|
|
|
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<div id="root"></div>
|
chatkit/frontend/package-lock.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "
|
| 9 |
-
"version": "
|
| 10 |
"dependencies": {
|
| 11 |
-
"
|
|
|
|
| 12 |
"react": "^19.2.0",
|
| 13 |
"react-dom": "^19.2.0"
|
| 14 |
},
|
|
@@ -48,6 +49,15 @@
|
|
| 48 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 49 |
}
|
| 50 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"node_modules/@emnapi/core": {
|
| 52 |
"version": "1.5.0",
|
| 53 |
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
|
@@ -847,23 +857,6 @@
|
|
| 847 |
"node": ">= 8"
|
| 848 |
}
|
| 849 |
},
|
| 850 |
-
"node_modules/@openai/chatkit": {
|
| 851 |
-
"version": "1.0.0",
|
| 852 |
-
"resolved": "https://registry.npmjs.org/@openai/chatkit/-/chatkit-1.0.0.tgz",
|
| 853 |
-
"integrity": "sha512-BlRu1UMz29z01bn0D2nA5lgSeo0Eu/5uNdGUMKchoFEDHWQ8ViCHDRFO45O6FT/cdqhXbYZOzIrjUryq6djk4w=="
|
| 854 |
-
},
|
| 855 |
-
"node_modules/@openai/chatkit-react": {
|
| 856 |
-
"version": "1.1.1",
|
| 857 |
-
"resolved": "https://registry.npmjs.org/@openai/chatkit-react/-/chatkit-react-1.1.1.tgz",
|
| 858 |
-
"integrity": "sha512-sbYZBqLkx5nEIRmBFXJ8OCsRB+0II1eu/9QSVJP4T73PdAsggTMPfxw8FUGioFFJcYrNVoFvYaBFHZVUC71jtw==",
|
| 859 |
-
"dependencies": {
|
| 860 |
-
"@openai/chatkit": "1.0.0"
|
| 861 |
-
},
|
| 862 |
-
"peerDependencies": {
|
| 863 |
-
"react": ">=18",
|
| 864 |
-
"react-dom": ">=18"
|
| 865 |
-
}
|
| 866 |
-
},
|
| 867 |
"node_modules/@rolldown/pluginutils": {
|
| 868 |
"version": "1.0.0-beta.27",
|
| 869 |
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
|
@@ -1677,6 +1670,19 @@
|
|
| 1677 |
"undici-types": "~6.21.0"
|
| 1678 |
}
|
| 1679 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1680 |
"node_modules/@types/react": {
|
| 1681 |
"version": "19.2.1",
|
| 1682 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.1.tgz",
|
|
@@ -1697,6 +1703,13 @@
|
|
| 1697 |
"@types/react": "^19.2.0"
|
| 1698 |
}
|
| 1699 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1700 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
| 1701 |
"version": "8.46.0",
|
| 1702 |
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
|
@@ -2038,18 +2051,6 @@
|
|
| 2038 |
}
|
| 2039 |
}
|
| 2040 |
},
|
| 2041 |
-
"node_modules/@vitejs/plugin-react-swc/node_modules/@swc/helpers": {
|
| 2042 |
-
"version": "0.5.17",
|
| 2043 |
-
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
| 2044 |
-
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
| 2045 |
-
"dev": true,
|
| 2046 |
-
"license": "Apache-2.0",
|
| 2047 |
-
"optional": true,
|
| 2048 |
-
"peer": true,
|
| 2049 |
-
"dependencies": {
|
| 2050 |
-
"tslib": "^2.8.0"
|
| 2051 |
-
}
|
| 2052 |
-
},
|
| 2053 |
"node_modules/acorn": {
|
| 2054 |
"version": "8.15.0",
|
| 2055 |
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
|
@@ -2120,6 +2121,15 @@
|
|
| 2120 |
"dev": true,
|
| 2121 |
"license": "MIT"
|
| 2122 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2123 |
"node_modules/brace-expansion": {
|
| 2124 |
"version": "1.1.12",
|
| 2125 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
|
@@ -2154,6 +2164,26 @@
|
|
| 2154 |
"node": ">=6"
|
| 2155 |
}
|
| 2156 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2157 |
"node_modules/chalk": {
|
| 2158 |
"version": "4.1.2",
|
| 2159 |
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
|
@@ -2208,6 +2238,18 @@
|
|
| 2208 |
"dev": true,
|
| 2209 |
"license": "MIT"
|
| 2210 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2211 |
"node_modules/cross-spawn": {
|
| 2212 |
"version": "7.0.6",
|
| 2213 |
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
@@ -2223,6 +2265,15 @@
|
|
| 2223 |
"node": ">= 8"
|
| 2224 |
}
|
| 2225 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2226 |
"node_modules/csstype": {
|
| 2227 |
"version": "3.1.3",
|
| 2228 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
|
@@ -2265,6 +2316,16 @@
|
|
| 2265 |
"node": ">=8"
|
| 2266 |
}
|
| 2267 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2268 |
"node_modules/enhanced-resolve": {
|
| 2269 |
"version": "5.18.3",
|
| 2270 |
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
|
@@ -2279,6 +2340,12 @@
|
|
| 2279 |
"node": ">=10.13.0"
|
| 2280 |
}
|
| 2281 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2282 |
"node_modules/esbuild": {
|
| 2283 |
"version": "0.25.12",
|
| 2284 |
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
|
@@ -2533,6 +2600,17 @@
|
|
| 2533 |
"dev": true,
|
| 2534 |
"license": "MIT"
|
| 2535 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2536 |
"node_modules/fastq": {
|
| 2537 |
"version": "1.19.1",
|
| 2538 |
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
|
@@ -2543,6 +2621,12 @@
|
|
| 2543 |
"reusify": "^1.0.4"
|
| 2544 |
}
|
| 2545 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2546 |
"node_modules/file-entry-cache": {
|
| 2547 |
"version": "8.0.0",
|
| 2548 |
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
|
@@ -2672,6 +2756,30 @@
|
|
| 2672 |
"node": ">=8"
|
| 2673 |
}
|
| 2674 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2675 |
"node_modules/ignore": {
|
| 2676 |
"version": "5.3.2",
|
| 2677 |
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
|
@@ -2709,6 +2817,12 @@
|
|
| 2709 |
"node": ">=0.8.19"
|
| 2710 |
}
|
| 2711 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2712 |
"node_modules/is-extglob": {
|
| 2713 |
"version": "2.1.1",
|
| 2714 |
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
@@ -2793,6 +2907,23 @@
|
|
| 2793 |
"dev": true,
|
| 2794 |
"license": "MIT"
|
| 2795 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2796 |
"node_modules/keyv": {
|
| 2797 |
"version": "4.5.4",
|
| 2798 |
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
|
@@ -3232,6 +3363,12 @@
|
|
| 3232 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 3233 |
}
|
| 3234 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3235 |
"node_modules/parent-module": {
|
| 3236 |
"version": "1.0.1",
|
| 3237 |
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
@@ -3265,6 +3402,13 @@
|
|
| 3265 |
"node": ">=8"
|
| 3266 |
}
|
| 3267 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3268 |
"node_modules/picocolors": {
|
| 3269 |
"version": "1.1.1",
|
| 3270 |
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
@@ -3355,6 +3499,16 @@
|
|
| 3355 |
],
|
| 3356 |
"license": "MIT"
|
| 3357 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3358 |
"node_modules/react": {
|
| 3359 |
"version": "19.2.0",
|
| 3360 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
|
@@ -3376,6 +3530,13 @@
|
|
| 3376 |
"react": "^19.2.0"
|
| 3377 |
}
|
| 3378 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3379 |
"node_modules/resolve-from": {
|
| 3380 |
"version": "4.0.0",
|
| 3381 |
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
|
@@ -3397,6 +3558,16 @@
|
|
| 3397 |
"node": ">=0.10.0"
|
| 3398 |
}
|
| 3399 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3400 |
"node_modules/rollup": {
|
| 3401 |
"version": "4.53.3",
|
| 3402 |
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
|
@@ -3515,6 +3686,16 @@
|
|
| 3515 |
"node": ">=0.10.0"
|
| 3516 |
}
|
| 3517 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3518 |
"node_modules/strip-json-comments": {
|
| 3519 |
"version": "3.1.1",
|
| 3520 |
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
|
@@ -3541,6 +3722,16 @@
|
|
| 3541 |
"node": ">=8"
|
| 3542 |
}
|
| 3543 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3544 |
"node_modules/tailwindcss": {
|
| 3545 |
"version": "4.1.14",
|
| 3546 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
|
@@ -3579,6 +3770,15 @@
|
|
| 3579 |
"node": ">=18"
|
| 3580 |
}
|
| 3581 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3582 |
"node_modules/tinyglobby": {
|
| 3583 |
"version": "0.2.15",
|
| 3584 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
@@ -3705,6 +3905,15 @@
|
|
| 3705 |
"punycode": "^2.1.0"
|
| 3706 |
}
|
| 3707 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3708 |
"node_modules/vite": {
|
| 3709 |
"version": "7.2.7",
|
| 3710 |
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "classlens-frontend",
|
| 3 |
+
"version": "2.0.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
+
"name": "classlens-frontend",
|
| 9 |
+
"version": "2.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"html2canvas": "^1.4.1",
|
| 12 |
+
"html2pdf.js": "^0.10.1",
|
| 13 |
"react": "^19.2.0",
|
| 14 |
"react-dom": "^19.2.0"
|
| 15 |
},
|
|
|
|
| 49 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 50 |
}
|
| 51 |
},
|
| 52 |
+
"node_modules/@babel/runtime": {
|
| 53 |
+
"version": "7.28.6",
|
| 54 |
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
| 55 |
+
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
| 56 |
+
"license": "MIT",
|
| 57 |
+
"engines": {
|
| 58 |
+
"node": ">=6.9.0"
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
"node_modules/@emnapi/core": {
|
| 62 |
"version": "1.5.0",
|
| 63 |
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
|
|
|
| 857 |
"node": ">= 8"
|
| 858 |
}
|
| 859 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
"node_modules/@rolldown/pluginutils": {
|
| 861 |
"version": "1.0.0-beta.27",
|
| 862 |
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
|
|
|
| 1670 |
"undici-types": "~6.21.0"
|
| 1671 |
}
|
| 1672 |
},
|
| 1673 |
+
"node_modules/@types/pako": {
|
| 1674 |
+
"version": "2.0.4",
|
| 1675 |
+
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
| 1676 |
+
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
| 1677 |
+
"license": "MIT"
|
| 1678 |
+
},
|
| 1679 |
+
"node_modules/@types/raf": {
|
| 1680 |
+
"version": "3.4.3",
|
| 1681 |
+
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
| 1682 |
+
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
| 1683 |
+
"license": "MIT",
|
| 1684 |
+
"optional": true
|
| 1685 |
+
},
|
| 1686 |
"node_modules/@types/react": {
|
| 1687 |
"version": "19.2.1",
|
| 1688 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.1.tgz",
|
|
|
|
| 1703 |
"@types/react": "^19.2.0"
|
| 1704 |
}
|
| 1705 |
},
|
| 1706 |
+
"node_modules/@types/trusted-types": {
|
| 1707 |
+
"version": "2.0.7",
|
| 1708 |
+
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
| 1709 |
+
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
| 1710 |
+
"license": "MIT",
|
| 1711 |
+
"optional": true
|
| 1712 |
+
},
|
| 1713 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
| 1714 |
"version": "8.46.0",
|
| 1715 |
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
|
|
|
| 2051 |
}
|
| 2052 |
}
|
| 2053 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2054 |
"node_modules/acorn": {
|
| 2055 |
"version": "8.15.0",
|
| 2056 |
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
|
|
|
| 2121 |
"dev": true,
|
| 2122 |
"license": "MIT"
|
| 2123 |
},
|
| 2124 |
+
"node_modules/base64-arraybuffer": {
|
| 2125 |
+
"version": "1.0.2",
|
| 2126 |
+
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
| 2127 |
+
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
| 2128 |
+
"license": "MIT",
|
| 2129 |
+
"engines": {
|
| 2130 |
+
"node": ">= 0.6.0"
|
| 2131 |
+
}
|
| 2132 |
+
},
|
| 2133 |
"node_modules/brace-expansion": {
|
| 2134 |
"version": "1.1.12",
|
| 2135 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
|
|
|
| 2164 |
"node": ">=6"
|
| 2165 |
}
|
| 2166 |
},
|
| 2167 |
+
"node_modules/canvg": {
|
| 2168 |
+
"version": "3.0.11",
|
| 2169 |
+
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
| 2170 |
+
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
| 2171 |
+
"license": "MIT",
|
| 2172 |
+
"optional": true,
|
| 2173 |
+
"dependencies": {
|
| 2174 |
+
"@babel/runtime": "^7.12.5",
|
| 2175 |
+
"@types/raf": "^3.4.0",
|
| 2176 |
+
"core-js": "^3.8.3",
|
| 2177 |
+
"raf": "^3.4.1",
|
| 2178 |
+
"regenerator-runtime": "^0.13.7",
|
| 2179 |
+
"rgbcolor": "^1.0.1",
|
| 2180 |
+
"stackblur-canvas": "^2.0.0",
|
| 2181 |
+
"svg-pathdata": "^6.0.3"
|
| 2182 |
+
},
|
| 2183 |
+
"engines": {
|
| 2184 |
+
"node": ">=10.0.0"
|
| 2185 |
+
}
|
| 2186 |
+
},
|
| 2187 |
"node_modules/chalk": {
|
| 2188 |
"version": "4.1.2",
|
| 2189 |
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
|
|
|
| 2238 |
"dev": true,
|
| 2239 |
"license": "MIT"
|
| 2240 |
},
|
| 2241 |
+
"node_modules/core-js": {
|
| 2242 |
+
"version": "3.48.0",
|
| 2243 |
+
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
|
| 2244 |
+
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
| 2245 |
+
"hasInstallScript": true,
|
| 2246 |
+
"license": "MIT",
|
| 2247 |
+
"optional": true,
|
| 2248 |
+
"funding": {
|
| 2249 |
+
"type": "opencollective",
|
| 2250 |
+
"url": "https://opencollective.com/core-js"
|
| 2251 |
+
}
|
| 2252 |
+
},
|
| 2253 |
"node_modules/cross-spawn": {
|
| 2254 |
"version": "7.0.6",
|
| 2255 |
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
|
|
| 2265 |
"node": ">= 8"
|
| 2266 |
}
|
| 2267 |
},
|
| 2268 |
+
"node_modules/css-line-break": {
|
| 2269 |
+
"version": "2.1.0",
|
| 2270 |
+
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
| 2271 |
+
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
| 2272 |
+
"license": "MIT",
|
| 2273 |
+
"dependencies": {
|
| 2274 |
+
"utrie": "^1.0.2"
|
| 2275 |
+
}
|
| 2276 |
+
},
|
| 2277 |
"node_modules/csstype": {
|
| 2278 |
"version": "3.1.3",
|
| 2279 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
|
|
|
| 2316 |
"node": ">=8"
|
| 2317 |
}
|
| 2318 |
},
|
| 2319 |
+
"node_modules/dompurify": {
|
| 2320 |
+
"version": "3.3.1",
|
| 2321 |
+
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
| 2322 |
+
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
| 2323 |
+
"license": "(MPL-2.0 OR Apache-2.0)",
|
| 2324 |
+
"optional": true,
|
| 2325 |
+
"optionalDependencies": {
|
| 2326 |
+
"@types/trusted-types": "^2.0.7"
|
| 2327 |
+
}
|
| 2328 |
+
},
|
| 2329 |
"node_modules/enhanced-resolve": {
|
| 2330 |
"version": "5.18.3",
|
| 2331 |
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
|
|
|
| 2340 |
"node": ">=10.13.0"
|
| 2341 |
}
|
| 2342 |
},
|
| 2343 |
+
"node_modules/es6-promise": {
|
| 2344 |
+
"version": "4.2.8",
|
| 2345 |
+
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
|
| 2346 |
+
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
|
| 2347 |
+
"license": "MIT"
|
| 2348 |
+
},
|
| 2349 |
"node_modules/esbuild": {
|
| 2350 |
"version": "0.25.12",
|
| 2351 |
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
|
|
|
| 2600 |
"dev": true,
|
| 2601 |
"license": "MIT"
|
| 2602 |
},
|
| 2603 |
+
"node_modules/fast-png": {
|
| 2604 |
+
"version": "6.4.0",
|
| 2605 |
+
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
| 2606 |
+
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
| 2607 |
+
"license": "MIT",
|
| 2608 |
+
"dependencies": {
|
| 2609 |
+
"@types/pako": "^2.0.3",
|
| 2610 |
+
"iobuffer": "^5.3.2",
|
| 2611 |
+
"pako": "^2.1.0"
|
| 2612 |
+
}
|
| 2613 |
+
},
|
| 2614 |
"node_modules/fastq": {
|
| 2615 |
"version": "1.19.1",
|
| 2616 |
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
|
|
|
| 2621 |
"reusify": "^1.0.4"
|
| 2622 |
}
|
| 2623 |
},
|
| 2624 |
+
"node_modules/fflate": {
|
| 2625 |
+
"version": "0.8.2",
|
| 2626 |
+
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
| 2627 |
+
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
| 2628 |
+
"license": "MIT"
|
| 2629 |
+
},
|
| 2630 |
"node_modules/file-entry-cache": {
|
| 2631 |
"version": "8.0.0",
|
| 2632 |
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
|
|
|
| 2756 |
"node": ">=8"
|
| 2757 |
}
|
| 2758 |
},
|
| 2759 |
+
"node_modules/html2canvas": {
|
| 2760 |
+
"version": "1.4.1",
|
| 2761 |
+
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
| 2762 |
+
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
| 2763 |
+
"license": "MIT",
|
| 2764 |
+
"dependencies": {
|
| 2765 |
+
"css-line-break": "^2.1.0",
|
| 2766 |
+
"text-segmentation": "^1.0.3"
|
| 2767 |
+
},
|
| 2768 |
+
"engines": {
|
| 2769 |
+
"node": ">=8.0.0"
|
| 2770 |
+
}
|
| 2771 |
+
},
|
| 2772 |
+
"node_modules/html2pdf.js": {
|
| 2773 |
+
"version": "0.10.3",
|
| 2774 |
+
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.10.3.tgz",
|
| 2775 |
+
"integrity": "sha512-RcB1sh8rs5NT3jgbN5zvvTmkmZrsUrxpZ/RI8TMbvuReNZAdJZG5TMfA2TBP6ZXxpXlWf9NB/ciLXVb6W2LbRQ==",
|
| 2776 |
+
"license": "MIT",
|
| 2777 |
+
"dependencies": {
|
| 2778 |
+
"es6-promise": "^4.2.5",
|
| 2779 |
+
"html2canvas": "^1.0.0",
|
| 2780 |
+
"jspdf": "^3.0.0"
|
| 2781 |
+
}
|
| 2782 |
+
},
|
| 2783 |
"node_modules/ignore": {
|
| 2784 |
"version": "5.3.2",
|
| 2785 |
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
|
|
|
| 2817 |
"node": ">=0.8.19"
|
| 2818 |
}
|
| 2819 |
},
|
| 2820 |
+
"node_modules/iobuffer": {
|
| 2821 |
+
"version": "5.4.0",
|
| 2822 |
+
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
| 2823 |
+
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
| 2824 |
+
"license": "MIT"
|
| 2825 |
+
},
|
| 2826 |
"node_modules/is-extglob": {
|
| 2827 |
"version": "2.1.1",
|
| 2828 |
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
|
|
| 2907 |
"dev": true,
|
| 2908 |
"license": "MIT"
|
| 2909 |
},
|
| 2910 |
+
"node_modules/jspdf": {
|
| 2911 |
+
"version": "3.0.4",
|
| 2912 |
+
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
|
| 2913 |
+
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
|
| 2914 |
+
"license": "MIT",
|
| 2915 |
+
"dependencies": {
|
| 2916 |
+
"@babel/runtime": "^7.28.4",
|
| 2917 |
+
"fast-png": "^6.2.0",
|
| 2918 |
+
"fflate": "^0.8.1"
|
| 2919 |
+
},
|
| 2920 |
+
"optionalDependencies": {
|
| 2921 |
+
"canvg": "^3.0.11",
|
| 2922 |
+
"core-js": "^3.6.0",
|
| 2923 |
+
"dompurify": "^3.2.4",
|
| 2924 |
+
"html2canvas": "^1.0.0-rc.5"
|
| 2925 |
+
}
|
| 2926 |
+
},
|
| 2927 |
"node_modules/keyv": {
|
| 2928 |
"version": "4.5.4",
|
| 2929 |
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
|
|
|
| 3363 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 3364 |
}
|
| 3365 |
},
|
| 3366 |
+
"node_modules/pako": {
|
| 3367 |
+
"version": "2.1.0",
|
| 3368 |
+
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
| 3369 |
+
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
| 3370 |
+
"license": "(MIT AND Zlib)"
|
| 3371 |
+
},
|
| 3372 |
"node_modules/parent-module": {
|
| 3373 |
"version": "1.0.1",
|
| 3374 |
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
|
|
| 3402 |
"node": ">=8"
|
| 3403 |
}
|
| 3404 |
},
|
| 3405 |
+
"node_modules/performance-now": {
|
| 3406 |
+
"version": "2.1.0",
|
| 3407 |
+
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
| 3408 |
+
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
| 3409 |
+
"license": "MIT",
|
| 3410 |
+
"optional": true
|
| 3411 |
+
},
|
| 3412 |
"node_modules/picocolors": {
|
| 3413 |
"version": "1.1.1",
|
| 3414 |
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
|
|
| 3499 |
],
|
| 3500 |
"license": "MIT"
|
| 3501 |
},
|
| 3502 |
+
"node_modules/raf": {
|
| 3503 |
+
"version": "3.4.1",
|
| 3504 |
+
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
| 3505 |
+
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
| 3506 |
+
"license": "MIT",
|
| 3507 |
+
"optional": true,
|
| 3508 |
+
"dependencies": {
|
| 3509 |
+
"performance-now": "^2.1.0"
|
| 3510 |
+
}
|
| 3511 |
+
},
|
| 3512 |
"node_modules/react": {
|
| 3513 |
"version": "19.2.0",
|
| 3514 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
|
|
|
| 3530 |
"react": "^19.2.0"
|
| 3531 |
}
|
| 3532 |
},
|
| 3533 |
+
"node_modules/regenerator-runtime": {
|
| 3534 |
+
"version": "0.13.11",
|
| 3535 |
+
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
| 3536 |
+
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
| 3537 |
+
"license": "MIT",
|
| 3538 |
+
"optional": true
|
| 3539 |
+
},
|
| 3540 |
"node_modules/resolve-from": {
|
| 3541 |
"version": "4.0.0",
|
| 3542 |
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
|
|
|
| 3558 |
"node": ">=0.10.0"
|
| 3559 |
}
|
| 3560 |
},
|
| 3561 |
+
"node_modules/rgbcolor": {
|
| 3562 |
+
"version": "1.0.1",
|
| 3563 |
+
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
| 3564 |
+
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
| 3565 |
+
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
| 3566 |
+
"optional": true,
|
| 3567 |
+
"engines": {
|
| 3568 |
+
"node": ">= 0.8.15"
|
| 3569 |
+
}
|
| 3570 |
+
},
|
| 3571 |
"node_modules/rollup": {
|
| 3572 |
"version": "4.53.3",
|
| 3573 |
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
|
|
|
| 3686 |
"node": ">=0.10.0"
|
| 3687 |
}
|
| 3688 |
},
|
| 3689 |
+
"node_modules/stackblur-canvas": {
|
| 3690 |
+
"version": "2.7.0",
|
| 3691 |
+
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
| 3692 |
+
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
| 3693 |
+
"license": "MIT",
|
| 3694 |
+
"optional": true,
|
| 3695 |
+
"engines": {
|
| 3696 |
+
"node": ">=0.1.14"
|
| 3697 |
+
}
|
| 3698 |
+
},
|
| 3699 |
"node_modules/strip-json-comments": {
|
| 3700 |
"version": "3.1.1",
|
| 3701 |
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
|
|
|
| 3722 |
"node": ">=8"
|
| 3723 |
}
|
| 3724 |
},
|
| 3725 |
+
"node_modules/svg-pathdata": {
|
| 3726 |
+
"version": "6.0.3",
|
| 3727 |
+
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
| 3728 |
+
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
| 3729 |
+
"license": "MIT",
|
| 3730 |
+
"optional": true,
|
| 3731 |
+
"engines": {
|
| 3732 |
+
"node": ">=12.0.0"
|
| 3733 |
+
}
|
| 3734 |
+
},
|
| 3735 |
"node_modules/tailwindcss": {
|
| 3736 |
"version": "4.1.14",
|
| 3737 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
|
|
|
| 3770 |
"node": ">=18"
|
| 3771 |
}
|
| 3772 |
},
|
| 3773 |
+
"node_modules/text-segmentation": {
|
| 3774 |
+
"version": "1.0.3",
|
| 3775 |
+
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
| 3776 |
+
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
| 3777 |
+
"license": "MIT",
|
| 3778 |
+
"dependencies": {
|
| 3779 |
+
"utrie": "^1.0.2"
|
| 3780 |
+
}
|
| 3781 |
+
},
|
| 3782 |
"node_modules/tinyglobby": {
|
| 3783 |
"version": "0.2.15",
|
| 3784 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
|
|
| 3905 |
"punycode": "^2.1.0"
|
| 3906 |
}
|
| 3907 |
},
|
| 3908 |
+
"node_modules/utrie": {
|
| 3909 |
+
"version": "1.0.2",
|
| 3910 |
+
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
| 3911 |
+
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
| 3912 |
+
"license": "MIT",
|
| 3913 |
+
"dependencies": {
|
| 3914 |
+
"base64-arraybuffer": "^1.0.2"
|
| 3915 |
+
}
|
| 3916 |
+
},
|
| 3917 |
"node_modules/vite": {
|
| 3918 |
"version": "7.2.7",
|
| 3919 |
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
chatkit/frontend/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "
|
| 4 |
-
"description": "
|
| 5 |
"private": true,
|
| 6 |
"type": "module",
|
| 7 |
"scripts": {
|
|
@@ -15,7 +15,8 @@
|
|
| 15 |
"npm": ">=9"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
-
"
|
|
|
|
| 19 |
"react": "^19.2.0",
|
| 20 |
"react-dom": "^19.2.0"
|
| 21 |
},
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "classlens-frontend",
|
| 3 |
+
"version": "2.0.0",
|
| 4 |
+
"description": "ClassLens - AI-Powered Exam Analysis for Teachers",
|
| 5 |
"private": true,
|
| 6 |
"type": "module",
|
| 7 |
"scripts": {
|
|
|
|
| 15 |
"npm": ">=9"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
+
"html2canvas": "^1.4.1",
|
| 19 |
+
"html2pdf.js": "^0.10.1",
|
| 20 |
"react": "^19.2.0",
|
| 21 |
"react-dom": "^19.2.0"
|
| 22 |
},
|
chatkit/frontend/src/App.tsx
CHANGED
|
@@ -1,176 +1,129 @@
|
|
| 1 |
-
import { useState, useEffect } from "react";
|
| 2 |
import { Header } from "./components/Header";
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 8 |
-
import {
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
const [isShaking, setIsShaking] = useState(false);
|
| 16 |
-
|
| 17 |
-
const handleSubmit = (e: React.FormEvent) => {
|
| 18 |
-
e.preventDefault();
|
| 19 |
-
if (code.trim().toLowerCase() === VALID_INVITE_CODE) {
|
| 20 |
-
// Save to localStorage so user doesn't need to enter again
|
| 21 |
-
localStorage.setItem("classlens_access", "granted");
|
| 22 |
-
onSuccess();
|
| 23 |
-
} else {
|
| 24 |
-
setError(true);
|
| 25 |
-
setIsShaking(true);
|
| 26 |
-
setTimeout(() => setIsShaking(false), 500);
|
| 27 |
-
}
|
| 28 |
-
};
|
| 29 |
-
|
| 30 |
-
return (
|
| 31 |
-
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f1419] via-[#1a2332] to-[#0f1419]">
|
| 32 |
-
<div className="relative">
|
| 33 |
-
{/* Decorative background elements */}
|
| 34 |
-
<div className="absolute -top-20 -left-20 w-40 h-40 bg-[var(--color-primary)]/20 rounded-full blur-3xl" />
|
| 35 |
-
<div className="absolute -bottom-20 -right-20 w-40 h-40 bg-[var(--color-accent)]/20 rounded-full blur-3xl" />
|
| 36 |
-
|
| 37 |
-
<div
|
| 38 |
-
className={`relative bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl p-8 max-w-md w-full shadow-2xl ${
|
| 39 |
-
isShaking ? "animate-shake" : ""
|
| 40 |
-
}`}
|
| 41 |
-
>
|
| 42 |
-
{/* Logo */}
|
| 43 |
-
<div className="text-center mb-8">
|
| 44 |
-
<img
|
| 45 |
-
src={CLASSLENS_ICON}
|
| 46 |
-
alt="ClassLens"
|
| 47 |
-
className="w-24 h-24 mx-auto mb-4 rounded-2xl shadow-lg"
|
| 48 |
-
/>
|
| 49 |
-
<h1 className="text-2xl font-bold text-[var(--color-text)] font-display">
|
| 50 |
-
ClassLens
|
| 51 |
-
</h1>
|
| 52 |
-
<p className="text-[var(--color-text-muted)] mt-2">
|
| 53 |
-
AI 驅動的考試分析
|
| 54 |
-
</p>
|
| 55 |
-
</div>
|
| 56 |
-
|
| 57 |
-
{/* Invite code form */}
|
| 58 |
-
<form onSubmit={handleSubmit} className="space-y-4">
|
| 59 |
-
<div>
|
| 60 |
-
<label
|
| 61 |
-
htmlFor="invite-code"
|
| 62 |
-
className="block text-sm font-medium text-[var(--color-text-muted)] mb-2"
|
| 63 |
-
>
|
| 64 |
-
輸入邀請碼
|
| 65 |
-
</label>
|
| 66 |
-
<input
|
| 67 |
-
id="invite-code"
|
| 68 |
-
type="text"
|
| 69 |
-
value={code}
|
| 70 |
-
onChange={(e) => {
|
| 71 |
-
setCode(e.target.value);
|
| 72 |
-
setError(false);
|
| 73 |
-
}}
|
| 74 |
-
placeholder="請輸入您的邀請碼..."
|
| 75 |
-
className={`w-full px-4 py-3 bg-[var(--color-background)] border rounded-xl text-[var(--color-text)] placeholder-[var(--color-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] transition-all ${
|
| 76 |
-
error
|
| 77 |
-
? "border-red-500 focus:ring-red-500"
|
| 78 |
-
: "border-[var(--color-border)]"
|
| 79 |
-
}`}
|
| 80 |
-
autoFocus
|
| 81 |
-
/>
|
| 82 |
-
{error && (
|
| 83 |
-
<p className="mt-2 text-sm text-red-400 flex items-center gap-1">
|
| 84 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 85 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 86 |
-
</svg>
|
| 87 |
-
邀請碼無效,請重試。
|
| 88 |
-
</p>
|
| 89 |
-
)}
|
| 90 |
-
</div>
|
| 91 |
-
|
| 92 |
-
<button
|
| 93 |
-
type="submit"
|
| 94 |
-
className="w-full py-3 px-4 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-accent)] text-white font-semibold rounded-xl hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)]"
|
| 95 |
-
>
|
| 96 |
-
進入應用程式 →
|
| 97 |
-
</button>
|
| 98 |
-
</form>
|
| 99 |
-
|
| 100 |
-
{/* Footer */}
|
| 101 |
-
<p className="text-center text-xs text-[var(--color-text-muted)] mt-6">
|
| 102 |
-
Don't have an invite code?{" "}
|
| 103 |
-
<a
|
| 104 |
-
href="mailto:kuanz1991@gmail.com"
|
| 105 |
-
className="text-[var(--color-primary)] hover:underline"
|
| 106 |
-
>
|
| 107 |
-
Contact us
|
| 108 |
-
</a>
|
| 109 |
-
</p>
|
| 110 |
-
</div>
|
| 111 |
-
</div>
|
| 112 |
-
|
| 113 |
-
{/* Add shake animation */}
|
| 114 |
-
<style>{`
|
| 115 |
-
@keyframes shake {
|
| 116 |
-
0%, 100% { transform: translateX(0); }
|
| 117 |
-
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
| 118 |
-
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
| 119 |
-
}
|
| 120 |
-
.animate-shake {
|
| 121 |
-
animation: shake 0.5s ease-in-out;
|
| 122 |
-
}
|
| 123 |
-
`}</style>
|
| 124 |
-
</div>
|
| 125 |
-
);
|
| 126 |
}
|
| 127 |
|
| 128 |
export default function App() {
|
| 129 |
-
const [
|
| 130 |
-
const [
|
| 131 |
-
const [
|
| 132 |
-
const [
|
| 133 |
-
const [
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
| 136 |
useEffect(() => {
|
| 137 |
-
const
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}, []);
|
| 140 |
|
| 141 |
-
//
|
| 142 |
useEffect(() => {
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
const test = params.get("test");
|
| 148 |
-
|
| 149 |
-
if (test === "chat") {
|
| 150 |
-
setTestMode(true);
|
| 151 |
-
return;
|
| 152 |
}
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
} else if (authError) {
|
| 161 |
-
console.error("Auth error:", authError);
|
| 162 |
-
window.history.replaceState({}, "", window.location.pathname);
|
| 163 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
}, []);
|
| 165 |
|
| 166 |
-
const
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
};
|
| 171 |
|
| 172 |
// Loading state
|
| 173 |
-
if (
|
| 174 |
return (
|
| 175 |
<div className="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
|
| 176 |
<div className="w-8 h-8 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
|
@@ -178,62 +131,84 @@ export default function App() {
|
|
| 178 |
);
|
| 179 |
}
|
| 180 |
|
| 181 |
-
//
|
| 182 |
-
if (!
|
| 183 |
-
return <
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
// Test mode - show simple chat panel
|
| 187 |
-
if (testMode) {
|
| 188 |
-
return (
|
| 189 |
-
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-100 dark:bg-slate-950 p-4">
|
| 190 |
-
<div className="mx-auto w-full max-w-3xl">
|
| 191 |
-
<div className="mb-4 text-center">
|
| 192 |
-
<h1 className="text-2xl font-bold mb-2">ChatKit Test Mode</h1>
|
| 193 |
-
<p className="text-gray-600">Testing basic ChatKit functionality</p>
|
| 194 |
-
<a href="/" className="text-blue-500 underline text-sm">← Back to app</a>
|
| 195 |
-
</div>
|
| 196 |
-
<SimpleChatPanel />
|
| 197 |
-
</div>
|
| 198 |
-
</main>
|
| 199 |
-
);
|
| 200 |
}
|
| 201 |
|
| 202 |
return (
|
| 203 |
<div className="min-h-screen">
|
| 204 |
-
<Header
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
</div>
|
| 238 |
);
|
| 239 |
}
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "react";
|
| 2 |
import { Header } from "./components/Header";
|
| 3 |
+
import { LoginForm } from "./components/LoginForm";
|
| 4 |
+
import { StepIndicator } from "./components/StepIndicator";
|
| 5 |
+
import { FileUploadPanel } from "./components/step1/FileUploadPanel";
|
| 6 |
+
import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
|
| 7 |
+
import { PromptEditor } from "./components/step2/PromptEditor";
|
| 8 |
+
import { ReportViewer } from "./components/step3/ReportViewer";
|
| 9 |
+
import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
|
| 10 |
+
|
| 11 |
+
interface User {
|
| 12 |
+
id: number;
|
| 13 |
+
email: string;
|
| 14 |
+
display_name: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export default function App() {
|
| 18 |
+
const [user, setUser] = useState<User | null>(null);
|
| 19 |
+
const [loading, setLoading] = useState(true);
|
| 20 |
+
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
| 21 |
+
const [sessionId, setSessionId] = useState<number | null>(null);
|
| 22 |
+
const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
|
| 23 |
+
const [promptContent, setPromptContent] = useState("");
|
| 24 |
+
const [reportHtml, setReportHtml] = useState("");
|
| 25 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 26 |
+
|
| 27 |
+
// Check for saved JWT on mount
|
| 28 |
useEffect(() => {
|
| 29 |
+
const token = getToken();
|
| 30 |
+
if (token) {
|
| 31 |
+
apiGet<User>("/api/auth/me")
|
| 32 |
+
.then(setUser)
|
| 33 |
+
.catch(() => clearToken())
|
| 34 |
+
.finally(() => setLoading(false));
|
| 35 |
+
} else {
|
| 36 |
+
setLoading(false);
|
| 37 |
+
}
|
| 38 |
}, []);
|
| 39 |
|
| 40 |
+
// Load default prompt
|
| 41 |
useEffect(() => {
|
| 42 |
+
if (user && !promptContent) {
|
| 43 |
+
apiGet<{ content: string }>("/api/prompts/default")
|
| 44 |
+
.then((res) => setPromptContent(res.content))
|
| 45 |
+
.catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
+
}, [user]);
|
| 48 |
|
| 49 |
+
// Create session when user is authenticated and no session exists
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
if (user && !sessionId) {
|
| 52 |
+
apiPost<{ id: number }>("/api/sessions", { title: "New Session" })
|
| 53 |
+
.then((session) => setSessionId(session.id))
|
| 54 |
+
.catch(() => {});
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
+
}, [user]);
|
| 57 |
+
|
| 58 |
+
const handleLogout = () => {
|
| 59 |
+
setUser(null);
|
| 60 |
+
setSessionId(null);
|
| 61 |
+
setParsedData({});
|
| 62 |
+
setPromptContent("");
|
| 63 |
+
setReportHtml("");
|
| 64 |
+
setCurrentStep(1);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleParsedDataUpdate = useCallback((dataType: string, data: unknown) => {
|
| 68 |
+
setParsedData((prev) => ({ ...prev, [dataType]: data }));
|
| 69 |
}, []);
|
| 70 |
|
| 71 |
+
const handleGoToStep2 = useCallback(() => {
|
| 72 |
+
setCurrentStep(2);
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
const handleRunSummary = useCallback(async (model: string) => {
|
| 76 |
+
if (!sessionId || !promptContent.trim()) return;
|
| 77 |
+
setIsGenerating(true);
|
| 78 |
+
try {
|
| 79 |
+
const res = await apiPost<{ report_id: number; html_content: string }>(
|
| 80 |
+
`/api/sessions/${sessionId}/generate-report`,
|
| 81 |
+
{ prompt_content: promptContent, model }
|
| 82 |
+
);
|
| 83 |
+
setReportHtml(res.html_content);
|
| 84 |
+
setCurrentStep(3);
|
| 85 |
+
} catch (err) {
|
| 86 |
+
alert(err instanceof Error ? err.message : "Report generation failed");
|
| 87 |
+
} finally {
|
| 88 |
+
setIsGenerating(false);
|
| 89 |
}
|
| 90 |
+
}, [sessionId, promptContent]);
|
| 91 |
+
|
| 92 |
+
const handleExportPdf = useCallback(async () => {
|
| 93 |
+
// Dynamic import to avoid loading html2pdf.js unless needed
|
| 94 |
+
const html2pdf = (await import("html2pdf.js")).default;
|
| 95 |
+
const container = document.createElement("div");
|
| 96 |
+
container.innerHTML = reportHtml;
|
| 97 |
+
document.body.appendChild(container);
|
| 98 |
+
await html2pdf()
|
| 99 |
+
.set({ margin: 10, filename: "classlens-report.pdf", html2canvas: { scale: 2 } })
|
| 100 |
+
.from(container)
|
| 101 |
+
.save();
|
| 102 |
+
document.body.removeChild(container);
|
| 103 |
+
}, [reportHtml]);
|
| 104 |
+
|
| 105 |
+
const handleExportPng = useCallback(async () => {
|
| 106 |
+
const html2canvas = (await import("html2canvas")).default;
|
| 107 |
+
const container = document.createElement("div");
|
| 108 |
+
container.innerHTML = reportHtml;
|
| 109 |
+
container.style.width = "1200px";
|
| 110 |
+
container.style.position = "absolute";
|
| 111 |
+
container.style.left = "-9999px";
|
| 112 |
+
document.body.appendChild(container);
|
| 113 |
+
const canvas = await html2canvas(container, { scale: 2 });
|
| 114 |
+
document.body.removeChild(container);
|
| 115 |
+
const link = document.createElement("a");
|
| 116 |
+
link.download = "classlens-report.png";
|
| 117 |
+
link.href = canvas.toDataURL("image/png");
|
| 118 |
+
link.click();
|
| 119 |
+
}, [reportHtml]);
|
| 120 |
+
|
| 121 |
+
const handleStepClick = (step: 1 | 2 | 3) => {
|
| 122 |
+
if (step <= currentStep) setCurrentStep(step);
|
| 123 |
};
|
| 124 |
|
| 125 |
// Loading state
|
| 126 |
+
if (loading) {
|
| 127 |
return (
|
| 128 |
<div className="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
|
| 129 |
<div className="w-8 h-8 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
|
|
|
| 131 |
);
|
| 132 |
}
|
| 133 |
|
| 134 |
+
// Login gate
|
| 135 |
+
if (!user) {
|
| 136 |
+
return <LoginForm onLogin={setUser} />;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
return (
|
| 140 |
<div className="min-h-screen">
|
| 141 |
+
<Header userEmail={user.email} onLogout={handleLogout} />
|
| 142 |
+
|
| 143 |
+
<div className="pt-20">
|
| 144 |
+
<StepIndicator currentStep={currentStep} onStepClick={handleStepClick} />
|
| 145 |
+
|
| 146 |
+
<main className="max-w-6xl mx-auto px-4 pb-12">
|
| 147 |
+
{currentStep === 1 && (
|
| 148 |
+
<div className="space-y-6 animate-fade-in-up">
|
| 149 |
+
<div className="text-center mb-8">
|
| 150 |
+
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
|
| 151 |
+
上傳考試資料
|
| 152 |
+
</h2>
|
| 153 |
+
<p className="text-[var(--color-text-muted)] mt-2">
|
| 154 |
+
上傳考試題目、學生答案和標準答案,分別解析後確認資料再前往下一步
|
| 155 |
+
</p>
|
| 156 |
+
</div>
|
| 157 |
+
{sessionId && (
|
| 158 |
+
<FileUploadPanel
|
| 159 |
+
sessionId={sessionId}
|
| 160 |
+
parsedData={parsedData}
|
| 161 |
+
onParsedDataUpdate={handleParsedDataUpdate}
|
| 162 |
+
onGoToStep2={handleGoToStep2}
|
| 163 |
+
/>
|
| 164 |
+
)}
|
| 165 |
+
</div>
|
| 166 |
+
)}
|
| 167 |
+
|
| 168 |
+
{currentStep === 2 && (
|
| 169 |
+
<div className="space-y-6 animate-fade-in-up">
|
| 170 |
+
<div className="text-center mb-4">
|
| 171 |
+
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
|
| 172 |
+
編輯分析提示詞
|
| 173 |
+
</h2>
|
| 174 |
+
<p className="text-[var(--color-text-muted)] mt-2">
|
| 175 |
+
編輯提示詞來自訂分析報告的內容與格式
|
| 176 |
+
</p>
|
| 177 |
+
</div>
|
| 178 |
+
<ParsedDataSummary parsedData={parsedData} />
|
| 179 |
+
{sessionId && (
|
| 180 |
+
<PromptEditor
|
| 181 |
+
promptContent={promptContent}
|
| 182 |
+
onPromptChange={setPromptContent}
|
| 183 |
+
sessionId={sessionId}
|
| 184 |
+
onRunSummary={handleRunSummary}
|
| 185 |
+
isGenerating={isGenerating}
|
| 186 |
+
/>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
|
| 191 |
+
{currentStep === 3 && (
|
| 192 |
+
<div className="animate-fade-in-up">
|
| 193 |
+
<div className="text-center mb-4">
|
| 194 |
+
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
|
| 195 |
+
分析報告
|
| 196 |
+
</h2>
|
| 197 |
+
</div>
|
| 198 |
+
<ReportViewer
|
| 199 |
+
reportHtml={reportHtml}
|
| 200 |
+
onBack={() => setCurrentStep(2)}
|
| 201 |
+
onExportPdf={handleExportPdf}
|
| 202 |
+
onExportPng={handleExportPng}
|
| 203 |
+
/>
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
</main>
|
| 207 |
+
|
| 208 |
+
<footer className="py-8 text-center text-sm text-[var(--color-text-muted)]">
|
| 209 |
+
<p>© 2026 ClassLens • AI-Powered Teaching Assistant</p>
|
| 210 |
+
</footer>
|
| 211 |
+
</div>
|
| 212 |
</div>
|
| 213 |
);
|
| 214 |
}
|
chatkit/frontend/src/TestChat.tsx
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 1 |
-
import { SimpleChatPanel } from "./components/SimpleChatPanel";
|
| 2 |
-
|
| 3 |
-
export function TestChat() {
|
| 4 |
-
return (
|
| 5 |
-
<main className="flex min-h-screen flex-col items-center justify-end bg-slate-100 dark:bg-slate-950 p-4">
|
| 6 |
-
<div className="mx-auto w-full max-w-3xl">
|
| 7 |
-
<h1 className="text-2xl font-bold mb-4 text-center">ChatKit Test</h1>
|
| 8 |
-
<SimpleChatPanel />
|
| 9 |
-
</div>
|
| 10 |
-
</main>
|
| 11 |
-
);
|
| 12 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/AuthStatus.tsx
DELETED
|
@@ -1,212 +0,0 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
-
import { GOOGLE_AUTH_URL, AUTH_STATUS_URL } from "../lib/config";
|
| 3 |
-
|
| 4 |
-
type AuthMode = 'google' | 'csv-only';
|
| 5 |
-
|
| 6 |
-
interface AuthStatusProps {
|
| 7 |
-
teacherEmail: string;
|
| 8 |
-
setTeacherEmail: (email: string) => void;
|
| 9 |
-
isAuthenticated: boolean;
|
| 10 |
-
setIsAuthenticated: (auth: boolean) => void;
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
export function AuthStatus({
|
| 14 |
-
teacherEmail,
|
| 15 |
-
setTeacherEmail,
|
| 16 |
-
isAuthenticated,
|
| 17 |
-
setIsAuthenticated,
|
| 18 |
-
}: AuthStatusProps) {
|
| 19 |
-
const [emailInput, setEmailInput] = useState("");
|
| 20 |
-
const [isChecking, setIsChecking] = useState(false);
|
| 21 |
-
const [error, setError] = useState<string | null>(null);
|
| 22 |
-
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
|
| 23 |
-
|
| 24 |
-
const checkAuthStatus = async (email: string) => {
|
| 25 |
-
try {
|
| 26 |
-
setIsChecking(true);
|
| 27 |
-
const response = await fetch(`${AUTH_STATUS_URL}?teacher_email=${encodeURIComponent(email)}`);
|
| 28 |
-
const data = await response.json();
|
| 29 |
-
|
| 30 |
-
if (data.authenticated) {
|
| 31 |
-
setTeacherEmail(email);
|
| 32 |
-
setIsAuthenticated(true);
|
| 33 |
-
}
|
| 34 |
-
return data.authenticated;
|
| 35 |
-
} catch (err) {
|
| 36 |
-
console.error("Error checking auth status:", err);
|
| 37 |
-
return false;
|
| 38 |
-
} finally {
|
| 39 |
-
setIsChecking(false);
|
| 40 |
-
}
|
| 41 |
-
};
|
| 42 |
-
|
| 43 |
-
const handleConnect = async (e: React.FormEvent) => {
|
| 44 |
-
e.preventDefault();
|
| 45 |
-
setError(null);
|
| 46 |
-
|
| 47 |
-
if (!emailInput.trim()) {
|
| 48 |
-
setError("請輸入您的電子郵件地址");
|
| 49 |
-
return;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
// Check if already authenticated
|
| 53 |
-
const alreadyAuth = await checkAuthStatus(emailInput);
|
| 54 |
-
if (alreadyAuth) {
|
| 55 |
-
return;
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
// Try to start OAuth - catch errors if not configured
|
| 59 |
-
try {
|
| 60 |
-
const response = await fetch(`${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`, {
|
| 61 |
-
method: 'GET',
|
| 62 |
-
redirect: 'manual'
|
| 63 |
-
});
|
| 64 |
-
|
| 65 |
-
// If we get a redirect, OAuth is configured - follow it
|
| 66 |
-
if (response.type === 'opaqueredirect' || response.status === 302) {
|
| 67 |
-
window.location.href = `${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`;
|
| 68 |
-
} else if (response.status === 500) {
|
| 69 |
-
const data = await response.json();
|
| 70 |
-
if (data.detail?.includes('not configured')) {
|
| 71 |
-
setAuthMode('csv-only');
|
| 72 |
-
setError("Google OAuth 未設定。您仍可在聊天中直接上傳 CSV 檔案使用應用程式!");
|
| 73 |
-
// Set as "connected" with just email for CSV fallback
|
| 74 |
-
setTeacherEmail(emailInput);
|
| 75 |
-
setIsAuthenticated(true);
|
| 76 |
-
} else {
|
| 77 |
-
setAuthMode('google');
|
| 78 |
-
setError(data.detail || "Failed to connect");
|
| 79 |
-
}
|
| 80 |
-
} else {
|
| 81 |
-
// Redirect to OAuth
|
| 82 |
-
window.location.href = `${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`;
|
| 83 |
-
}
|
| 84 |
-
} catch (err) {
|
| 85 |
-
// Network error or CORS - just redirect
|
| 86 |
-
window.location.href = `${GOOGLE_AUTH_URL}?teacher_email=${encodeURIComponent(emailInput)}`;
|
| 87 |
-
}
|
| 88 |
-
};
|
| 89 |
-
|
| 90 |
-
const handleDisconnect = async () => {
|
| 91 |
-
try {
|
| 92 |
-
await fetch(`/auth/disconnect?teacher_email=${encodeURIComponent(teacherEmail)}`, {
|
| 93 |
-
method: 'POST'
|
| 94 |
-
});
|
| 95 |
-
setIsAuthenticated(false);
|
| 96 |
-
setTeacherEmail("");
|
| 97 |
-
} catch (err) {
|
| 98 |
-
console.error("Error disconnecting:", err);
|
| 99 |
-
}
|
| 100 |
-
};
|
| 101 |
-
|
| 102 |
-
return (
|
| 103 |
-
<section id="connect" className="py-20 px-6">
|
| 104 |
-
<div className="max-w-xl mx-auto">
|
| 105 |
-
<div className="card p-8">
|
| 106 |
-
<div className="text-center mb-8">
|
| 107 |
-
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-light)] flex items-center justify-center shadow-lg">
|
| 108 |
-
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 109 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
| 110 |
-
</svg>
|
| 111 |
-
</div>
|
| 112 |
-
<h2 className="font-display text-2xl font-bold text-[var(--color-text)] mb-2">
|
| 113 |
-
{isAuthenticated ? "已連接!" : "連接您的 Google 帳號"}
|
| 114 |
-
</h2>
|
| 115 |
-
<p className="text-[var(--color-text-muted)]">
|
| 116 |
-
{isAuthenticated
|
| 117 |
-
? "您的 Google 帳號已連結。現在可以分析考試答案了。"
|
| 118 |
-
: "我們只會存取您的 Google 表單回應試算表。不會在我們的伺服器上儲存任何資料。"}
|
| 119 |
-
</p>
|
| 120 |
-
</div>
|
| 121 |
-
|
| 122 |
-
{isAuthenticated ? (
|
| 123 |
-
<div className="space-y-4">
|
| 124 |
-
<div className="flex items-center justify-center gap-3 p-4 rounded-xl bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
|
| 125 |
-
<div className="w-10 h-10 rounded-full bg-[var(--color-success)]/20 flex items-center justify-center">
|
| 126 |
-
<svg className="w-5 h-5 text-[var(--color-success)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 127 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
| 128 |
-
</svg>
|
| 129 |
-
</div>
|
| 130 |
-
<div className="text-left">
|
| 131 |
-
<div className="font-medium text-[var(--color-text)]">{teacherEmail}</div>
|
| 132 |
-
<div className="text-sm text-[var(--color-text-muted)]">
|
| 133 |
-
{authMode === 'csv-only' ? 'CSV 上傳模式(Google OAuth 未設定)' : 'Google 帳號已連接'}
|
| 134 |
-
</div>
|
| 135 |
-
</div>
|
| 136 |
-
</div>
|
| 137 |
-
|
| 138 |
-
{authMode === 'csv-only' && (
|
| 139 |
-
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm">
|
| 140 |
-
<strong>注意:</strong>您可以在聊天中直接貼上 CSV 資料,或設定 Google OAuth 從 Google 表單取得資料。
|
| 141 |
-
</div>
|
| 142 |
-
)}
|
| 143 |
-
|
| 144 |
-
<button
|
| 145 |
-
onClick={handleDisconnect}
|
| 146 |
-
className="w-full btn btn-outline text-red-500 border-red-200 hover:border-red-500 hover:text-red-600"
|
| 147 |
-
>
|
| 148 |
-
斷開帳號
|
| 149 |
-
</button>
|
| 150 |
-
</div>
|
| 151 |
-
) : (
|
| 152 |
-
<form onSubmit={handleConnect} className="space-y-4">
|
| 153 |
-
<div>
|
| 154 |
-
<label htmlFor="email" className="block text-sm font-medium text-[var(--color-text)] mb-2">
|
| 155 |
-
您的學校電子郵件
|
| 156 |
-
</label>
|
| 157 |
-
<input
|
| 158 |
-
type="email"
|
| 159 |
-
id="email"
|
| 160 |
-
value={emailInput}
|
| 161 |
-
onChange={(e) => setEmailInput(e.target.value)}
|
| 162 |
-
placeholder="teacher@school.edu"
|
| 163 |
-
className="input"
|
| 164 |
-
required
|
| 165 |
-
/>
|
| 166 |
-
</div>
|
| 167 |
-
|
| 168 |
-
{error && (
|
| 169 |
-
<div className="p-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
| 170 |
-
{error}
|
| 171 |
-
</div>
|
| 172 |
-
)}
|
| 173 |
-
|
| 174 |
-
<button
|
| 175 |
-
type="submit"
|
| 176 |
-
className="w-full btn btn-primary"
|
| 177 |
-
disabled={isChecking}
|
| 178 |
-
>
|
| 179 |
-
{isChecking ? (
|
| 180 |
-
<>
|
| 181 |
-
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
| 182 |
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 183 |
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 184 |
-
</svg>
|
| 185 |
-
檢查中...
|
| 186 |
-
</>
|
| 187 |
-
) : (
|
| 188 |
-
<>
|
| 189 |
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
| 190 |
-
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
| 191 |
-
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
| 192 |
-
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 193 |
-
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 194 |
-
</svg>
|
| 195 |
-
使用 Google 繼續
|
| 196 |
-
</>
|
| 197 |
-
)}
|
| 198 |
-
</button>
|
| 199 |
-
|
| 200 |
-
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
| 201 |
-
連接即表示您同意我們的{" "}
|
| 202 |
-
<a href="#" className="underline hover:text-[var(--color-primary)]">服務條款</a>
|
| 203 |
-
{" "}和{" "}
|
| 204 |
-
<a href="#" className="underline hover:text-[var(--color-primary)]">隱私政策</a>
|
| 205 |
-
</p>
|
| 206 |
-
</form>
|
| 207 |
-
)}
|
| 208 |
-
</div>
|
| 209 |
-
</div>
|
| 210 |
-
</section>
|
| 211 |
-
);
|
| 212 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/ExamAnalyzer.tsx
DELETED
|
@@ -1,237 +0,0 @@
|
|
| 1 |
-
import { useState, useEffect } from "react";
|
| 2 |
-
import { ChatKit, useChatKit } from "@openai/chatkit-react";
|
| 3 |
-
import { CHATKIT_API_DOMAIN_KEY, CHATKIT_API_URL } from "../lib/config";
|
| 4 |
-
import { ReasoningPanel } from "./ReasoningPanel";
|
| 5 |
-
|
| 6 |
-
interface ExamAnalyzerProps {
|
| 7 |
-
teacherEmail: string;
|
| 8 |
-
onBack: () => void;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
export function ExamAnalyzer({ teacherEmail, onBack }: ExamAnalyzerProps) {
|
| 12 |
-
const [showCopiedToast, setShowCopiedToast] = useState(false);
|
| 13 |
-
const [selectedModel, setSelectedModel] = useState("gpt-4.1-mini");
|
| 14 |
-
const [availableModels, setAvailableModels] = useState<Record<string, {name: string; description: string; cost: string}>>({});
|
| 15 |
-
|
| 16 |
-
// Load available models
|
| 17 |
-
useEffect(() => {
|
| 18 |
-
fetch("/api/models")
|
| 19 |
-
.then(res => res.json())
|
| 20 |
-
.then(data => {
|
| 21 |
-
setAvailableModels(data.models || {});
|
| 22 |
-
setSelectedModel(data.default || "gpt-4.1-mini");
|
| 23 |
-
})
|
| 24 |
-
.catch(err => console.error("Failed to load models:", err));
|
| 25 |
-
}, []);
|
| 26 |
-
|
| 27 |
-
// Force Traditional Chinese locale for ChatKit
|
| 28 |
-
useEffect(() => {
|
| 29 |
-
// Set document language to zh-TW
|
| 30 |
-
document.documentElement.lang = 'zh-TW';
|
| 31 |
-
|
| 32 |
-
// Try to override browser language detection
|
| 33 |
-
if (navigator.language) {
|
| 34 |
-
Object.defineProperty(navigator, 'language', {
|
| 35 |
-
get: () => 'zh-TW',
|
| 36 |
-
configurable: true,
|
| 37 |
-
});
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
// Set Accept-Language header hint (may not work for iframe)
|
| 41 |
-
const meta = document.createElement('meta');
|
| 42 |
-
meta.httpEquiv = 'Content-Language';
|
| 43 |
-
meta.content = 'zh-TW';
|
| 44 |
-
document.head.appendChild(meta);
|
| 45 |
-
}, []);
|
| 46 |
-
|
| 47 |
-
// Store selected model in sessionStorage for backend access
|
| 48 |
-
useEffect(() => {
|
| 49 |
-
sessionStorage.setItem("selected_model", selectedModel);
|
| 50 |
-
}, [selectedModel]);
|
| 51 |
-
|
| 52 |
-
const chatkit = useChatKit({
|
| 53 |
-
api: {
|
| 54 |
-
url: CHATKIT_API_URL,
|
| 55 |
-
domainKey: CHATKIT_API_DOMAIN_KEY,
|
| 56 |
-
},
|
| 57 |
-
composer: {
|
| 58 |
-
attachments: { enabled: false },
|
| 59 |
-
},
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
const handleCopyPrompt = (prompt: string) => {
|
| 63 |
-
navigator.clipboard.writeText(prompt);
|
| 64 |
-
setShowCopiedToast(true);
|
| 65 |
-
setTimeout(() => setShowCopiedToast(false), 2000);
|
| 66 |
-
};
|
| 67 |
-
|
| 68 |
-
const examplePrompts = [
|
| 69 |
-
{
|
| 70 |
-
title: "📊 分析 Google 試算表",
|
| 71 |
-
prompt: `請分析這個 Google 試算表中的考試答案:
|
| 72 |
-
https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit
|
| 73 |
-
|
| 74 |
-
標準答案是:
|
| 75 |
-
- Q1: 4
|
| 76 |
-
- Q2: 加速度是速度的變化率
|
| 77 |
-
|
| 78 |
-
我的電子郵件是 ${teacherEmail}。請評分、解釋錯誤答案、建議同儕學習小組,並將報告發送到我的電子郵件。`,
|
| 79 |
-
},
|
| 80 |
-
{
|
| 81 |
-
title: "📝 分析 CSV 資料",
|
| 82 |
-
prompt: `請分析這些考試資料:
|
| 83 |
-
|
| 84 |
-
Timestamp,Email,Student Name,Q1 (2+2),Q2 (Explain acceleration)
|
| 85 |
-
2026-01-26 08:00:00,alice@student.edu,Alice,4,Acceleration is the rate of change of velocity
|
| 86 |
-
2026-01-26 08:01:00,bob@student.edu,Bob,3,I dont know
|
| 87 |
-
2026-01-26 08:02:00,carol@student.edu,Carol,4,velocity change over time
|
| 88 |
-
2026-01-26 08:03:00,david@student.edu,David,5,speed
|
| 89 |
-
|
| 90 |
-
標準答案是:
|
| 91 |
-
- Q1: 4
|
| 92 |
-
- Q2: Acceleration is the rate of change of velocity over time
|
| 93 |
-
|
| 94 |
-
我的電子郵件是 ${teacherEmail}。請評分並建立完整報告。`,
|
| 95 |
-
},
|
| 96 |
-
{
|
| 97 |
-
title: "⚡ 快速摘要",
|
| 98 |
-
prompt: `請給我班級表現的快速摘要。我的電子郵件是 ${teacherEmail}。`,
|
| 99 |
-
},
|
| 100 |
-
];
|
| 101 |
-
|
| 102 |
-
return (
|
| 103 |
-
<div className="min-h-screen pt-24 pb-8 px-4">
|
| 104 |
-
{/* Copied toast */}
|
| 105 |
-
{showCopiedToast && (
|
| 106 |
-
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg">
|
| 107 |
-
✓ 提示已複製!請在聊天中貼上。
|
| 108 |
-
</div>
|
| 109 |
-
)}
|
| 110 |
-
|
| 111 |
-
<div className="max-w-7xl mx-auto">
|
| 112 |
-
{/* Top bar */}
|
| 113 |
-
<div className="flex items-center justify-between mb-6">
|
| 114 |
-
<button
|
| 115 |
-
onClick={onBack}
|
| 116 |
-
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors"
|
| 117 |
-
>
|
| 118 |
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 119 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
| 120 |
-
</svg>
|
| 121 |
-
返回首頁
|
| 122 |
-
</button>
|
| 123 |
-
|
| 124 |
-
<div className="flex items-center gap-3">
|
| 125 |
-
<span className="text-sm text-gray-500">已連接為</span>
|
| 126 |
-
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
| 127 |
-
{teacherEmail}
|
| 128 |
-
</span>
|
| 129 |
-
<div className="flex items-center gap-2">
|
| 130 |
-
<span className="text-sm text-gray-500">模型:</span>
|
| 131 |
-
<select
|
| 132 |
-
value={selectedModel}
|
| 133 |
-
onChange={(e) => setSelectedModel(e.target.value)}
|
| 134 |
-
className="px-3 py-1 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 135 |
-
>
|
| 136 |
-
{Object.entries(availableModels).map(([key, model]) => (
|
| 137 |
-
<option key={key} value={key}>
|
| 138 |
-
{model.name} {key === "gpt-4.1-mini" ? "(默認)" : ""} - {model.description}
|
| 139 |
-
</option>
|
| 140 |
-
))}
|
| 141 |
-
</select>
|
| 142 |
-
</div>
|
| 143 |
-
</div>
|
| 144 |
-
</div>
|
| 145 |
-
|
| 146 |
-
<div className="grid lg:grid-cols-4 gap-6">
|
| 147 |
-
{/* Left Sidebar - AI Reasoning */}
|
| 148 |
-
<div className="lg:col-span-1 space-y-4">
|
| 149 |
-
{/* AI Reasoning Panel - Shows LLM thinking process */}
|
| 150 |
-
<ReasoningPanel sessionId="default" />
|
| 151 |
-
|
| 152 |
-
{/* Example Prompts */}
|
| 153 |
-
<div className="bg-white rounded-xl shadow-sm border p-4">
|
| 154 |
-
<h3 className="text-sm font-semibold text-gray-800 mb-3">
|
| 155 |
-
快速開始提示
|
| 156 |
-
</h3>
|
| 157 |
-
<div className="space-y-2">
|
| 158 |
-
{examplePrompts.map((example, i) => (
|
| 159 |
-
<button
|
| 160 |
-
key={i}
|
| 161 |
-
onClick={() => handleCopyPrompt(example.prompt)}
|
| 162 |
-
className="w-full text-left p-3 rounded-lg bg-gray-50 hover:bg-blue-50 transition-colors group border border-transparent hover:border-blue-200"
|
| 163 |
-
>
|
| 164 |
-
<div className="text-sm font-medium text-gray-700 group-hover:text-blue-600">
|
| 165 |
-
{example.title}
|
| 166 |
-
</div>
|
| 167 |
-
<div className="text-xs text-gray-500">
|
| 168 |
-
點擊複製
|
| 169 |
-
</div>
|
| 170 |
-
</button>
|
| 171 |
-
))}
|
| 172 |
-
</div>
|
| 173 |
-
</div>
|
| 174 |
-
|
| 175 |
-
{/* Tips */}
|
| 176 |
-
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-4 border border-blue-100">
|
| 177 |
-
<h3 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
| 178 |
-
💡 專業提示
|
| 179 |
-
</h3>
|
| 180 |
-
<ul className="space-y-1.5 text-xs text-gray-600">
|
| 181 |
-
<li className="flex items-start gap-1.5">
|
| 182 |
-
<span className="text-green-500">✓</span>
|
| 183 |
-
包含標準答案以提高準確度
|
| 184 |
-
</li>
|
| 185 |
-
<li className="flex items-start gap-1.5">
|
| 186 |
-
<span className="text-green-500">✓</span>
|
| 187 |
-
將 Google 試算表設為公開,或使用 CSV
|
| 188 |
-
</li>
|
| 189 |
-
<li className="flex items-start gap-1.5">
|
| 190 |
-
<span className="text-green-500">✓</span>
|
| 191 |
-
要求以電子郵件發送報告
|
| 192 |
-
</li>
|
| 193 |
-
</ul>
|
| 194 |
-
</div>
|
| 195 |
-
</div>
|
| 196 |
-
|
| 197 |
-
{/* Main Chat Area - Use same structure as SimpleChatPanel */}
|
| 198 |
-
<div className="lg:col-span-3">
|
| 199 |
-
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
| 200 |
-
{/* Chat Header */}
|
| 201 |
-
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 px-6 py-4">
|
| 202 |
-
<div className="flex items-center justify-between">
|
| 203 |
-
<div className="flex items-center gap-3">
|
| 204 |
-
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
| 205 |
-
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 206 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
| 207 |
-
</svg>
|
| 208 |
-
</div>
|
| 209 |
-
<div>
|
| 210 |
-
<h2 className="text-lg font-semibold text-white">
|
| 211 |
-
ClassLens 助手
|
| 212 |
-
</h2>
|
| 213 |
-
<p className="text-sm text-white/70">
|
| 214 |
-
請在下方貼上您的考試資料或 Google 試算表網址
|
| 215 |
-
</p>
|
| 216 |
-
</div>
|
| 217 |
-
</div>
|
| 218 |
-
<span className="px-2 py-1 rounded-full bg-white/20 text-white text-xs">
|
| 219 |
-
{availableModels[selectedModel]?.name || selectedModel}
|
| 220 |
-
</span>
|
| 221 |
-
</div>
|
| 222 |
-
</div>
|
| 223 |
-
|
| 224 |
-
{/* ChatKit - Using the EXACT same structure as SimpleChatPanel that works */}
|
| 225 |
-
<div className="h-[calc(100vh-320px)]">
|
| 226 |
-
<ChatKit
|
| 227 |
-
control={chatkit.control}
|
| 228 |
-
className="block h-full w-full"
|
| 229 |
-
/>
|
| 230 |
-
</div>
|
| 231 |
-
</div>
|
| 232 |
-
</div>
|
| 233 |
-
</div>
|
| 234 |
-
</div>
|
| 235 |
-
</div>
|
| 236 |
-
);
|
| 237 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/Features.tsx
DELETED
|
@@ -1,136 +0,0 @@
|
|
| 1 |
-
export function Features() {
|
| 2 |
-
const features = [
|
| 3 |
-
{
|
| 4 |
-
icon: (
|
| 5 |
-
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 6 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 7 |
-
</svg>
|
| 8 |
-
),
|
| 9 |
-
title: "自動評分",
|
| 10 |
-
description: "使用 AI 精準評分選擇題、數值題,甚至開放式問題。",
|
| 11 |
-
color: "var(--color-primary)",
|
| 12 |
-
},
|
| 13 |
-
{
|
| 14 |
-
icon: (
|
| 15 |
-
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 16 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
| 17 |
-
</svg>
|
| 18 |
-
),
|
| 19 |
-
title: "智能解釋",
|
| 20 |
-
description: "為每個錯誤答案提供個人化解釋,幫助學生理解錯誤。",
|
| 21 |
-
color: "var(--color-accent)",
|
| 22 |
-
},
|
| 23 |
-
{
|
| 24 |
-
icon: (
|
| 25 |
-
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 26 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
| 27 |
-
</svg>
|
| 28 |
-
),
|
| 29 |
-
title: "同儕學習小組",
|
| 30 |
-
description: "AI 建議的學生分組,將表現優秀的學生與需要特定主題幫助的學生配對。",
|
| 31 |
-
color: "var(--color-success)",
|
| 32 |
-
},
|
| 33 |
-
{
|
| 34 |
-
icon: (
|
| 35 |
-
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 36 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
| 37 |
-
</svg>
|
| 38 |
-
),
|
| 39 |
-
title: "電子郵件報告",
|
| 40 |
-
description: "直接在收件匣中接收精美的格式化報告,隨時可與學生或家長分享。",
|
| 41 |
-
color: "var(--color-warning)",
|
| 42 |
-
},
|
| 43 |
-
{
|
| 44 |
-
icon: (
|
| 45 |
-
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 46 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
| 47 |
-
</svg>
|
| 48 |
-
),
|
| 49 |
-
title: "隱私優先",
|
| 50 |
-
description: "學生資料安全處理。我們只存取您分享的內容,絕不儲存敏感資訊。",
|
| 51 |
-
color: "var(--color-primary-light)",
|
| 52 |
-
},
|
| 53 |
-
{
|
| 54 |
-
icon: (
|
| 55 |
-
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 56 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 57 |
-
</svg>
|
| 58 |
-
),
|
| 59 |
-
title: "即時分析",
|
| 60 |
-
description: "幾秒內獲得全面洞察,而非數小時。更多時間教學,更少時間評分。",
|
| 61 |
-
color: "var(--color-accent-light)",
|
| 62 |
-
},
|
| 63 |
-
];
|
| 64 |
-
|
| 65 |
-
return (
|
| 66 |
-
<section id="features" className="py-20 px-6 bg-gradient-to-b from-transparent to-[var(--color-surface)]/50">
|
| 67 |
-
<div className="max-w-6xl mx-auto">
|
| 68 |
-
<div className="text-center mb-16">
|
| 69 |
-
<h2 className="font-display text-4xl font-bold text-[var(--color-text)] mb-4">
|
| 70 |
-
了解您的學生所需的一切
|
| 71 |
-
<span className="text-gradient"> 功能</span>
|
| 72 |
-
</h2>
|
| 73 |
-
<p className="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
|
| 74 |
-
ClassLens 結合 AI 的力量與深思熟慮的教育實踐,為您提供可操作的洞察。
|
| 75 |
-
</p>
|
| 76 |
-
</div>
|
| 77 |
-
|
| 78 |
-
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 79 |
-
{features.map((feature, i) => (
|
| 80 |
-
<div
|
| 81 |
-
key={i}
|
| 82 |
-
className="card p-6 hover:shadow-lg transition-all hover:-translate-y-1 group"
|
| 83 |
-
>
|
| 84 |
-
<div
|
| 85 |
-
className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-transform group-hover:scale-110"
|
| 86 |
-
style={{
|
| 87 |
-
backgroundColor: `color-mix(in srgb, ${feature.color} 15%, transparent)`,
|
| 88 |
-
color: feature.color
|
| 89 |
-
}}
|
| 90 |
-
>
|
| 91 |
-
{feature.icon}
|
| 92 |
-
</div>
|
| 93 |
-
<h3 className="font-display text-xl font-semibold text-[var(--color-text)] mb-2">
|
| 94 |
-
{feature.title}
|
| 95 |
-
</h3>
|
| 96 |
-
<p className="text-[var(--color-text-muted)]">
|
| 97 |
-
{feature.description}
|
| 98 |
-
</p>
|
| 99 |
-
</div>
|
| 100 |
-
))}
|
| 101 |
-
</div>
|
| 102 |
-
|
| 103 |
-
{/* How it works */}
|
| 104 |
-
<div className="mt-24">
|
| 105 |
-
<h3 className="font-display text-3xl font-bold text-center text-[var(--color-text)] mb-12">
|
| 106 |
-
How It Works
|
| 107 |
-
</h3>
|
| 108 |
-
|
| 109 |
-
<div className="relative">
|
| 110 |
-
{/* Connection line */}
|
| 111 |
-
<div className="hidden lg:block absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-accent)] to-[var(--color-success)] -translate-y-1/2" />
|
| 112 |
-
|
| 113 |
-
<div className="grid lg:grid-cols-4 gap-8">
|
| 114 |
-
{[
|
| 115 |
-
{ step: 1, title: "Connect Google", desc: "Link your Google account to access Form responses" },
|
| 116 |
-
{ step: 2, title: "Share URL", desc: "Paste your Google Form or Sheets response URL" },
|
| 117 |
-
{ step: 3, title: "AI Analyzes", desc: "ClassLens grades, explains, and groups students" },
|
| 118 |
-
{ step: 4, title: "Get Report", desc: "Receive a beautiful report via email and dashboard" },
|
| 119 |
-
].map((item) => (
|
| 120 |
-
<div key={item.step} className="relative text-center">
|
| 121 |
-
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-accent)] text-white font-bold text-xl flex items-center justify-center shadow-lg relative z-10">
|
| 122 |
-
{item.step}
|
| 123 |
-
</div>
|
| 124 |
-
<h4 className="font-display text-lg font-semibold text-[var(--color-text)] mb-2">
|
| 125 |
-
{item.title}
|
| 126 |
-
</h4>
|
| 127 |
-
<p className="text-sm text-[var(--color-text-muted)]">{item.desc}</p>
|
| 128 |
-
</div>
|
| 129 |
-
))}
|
| 130 |
-
</div>
|
| 131 |
-
</div>
|
| 132 |
-
</div>
|
| 133 |
-
</div>
|
| 134 |
-
</section>
|
| 135 |
-
);
|
| 136 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/Header.tsx
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
|
|
|
|
|
| 1 |
interface HeaderProps {
|
| 2 |
-
|
| 3 |
-
|
| 4 |
}
|
| 5 |
|
| 6 |
-
export function Header({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
return (
|
| 8 |
<header className="fixed top-0 left-0 right-0 z-50 backdrop-blur-xl bg-[var(--glass-bg)] border-b border-[var(--glass-border)]">
|
| 9 |
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
| 10 |
<div className="flex items-center gap-3">
|
| 11 |
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-accent)] flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
| 12 |
-
|
| 13 |
</div>
|
| 14 |
<div>
|
| 15 |
<h1 className="font-display text-xl font-semibold text-[var(--color-text)]">
|
|
@@ -20,25 +27,21 @@ export function Header({ isAuthenticated, teacherEmail }: HeaderProps) {
|
|
| 20 |
</p>
|
| 21 |
</div>
|
| 22 |
</div>
|
| 23 |
-
|
| 24 |
-
<
|
| 25 |
-
|
| 26 |
-
<
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
href="https://platform.openai.com/docs/guides/chatkit"
|
| 35 |
-
target="_blank"
|
| 36 |
-
rel="noopener noreferrer"
|
| 37 |
-
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors"
|
| 38 |
>
|
| 39 |
-
|
| 40 |
-
</
|
| 41 |
-
</
|
| 42 |
</div>
|
| 43 |
</header>
|
| 44 |
);
|
|
|
|
| 1 |
+
import { clearToken } from "../lib/api";
|
| 2 |
+
|
| 3 |
interface HeaderProps {
|
| 4 |
+
userEmail: string;
|
| 5 |
+
onLogout: () => void;
|
| 6 |
}
|
| 7 |
|
| 8 |
+
export function Header({ userEmail, onLogout }: HeaderProps) {
|
| 9 |
+
const handleLogout = () => {
|
| 10 |
+
clearToken();
|
| 11 |
+
onLogout();
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
return (
|
| 15 |
<header className="fixed top-0 left-0 right-0 z-50 backdrop-blur-xl bg-[var(--glass-bg)] border-b border-[var(--glass-border)]">
|
| 16 |
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
| 17 |
<div className="flex items-center gap-3">
|
| 18 |
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-accent)] flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
| 19 |
+
C
|
| 20 |
</div>
|
| 21 |
<div>
|
| 22 |
<h1 className="font-display text-xl font-semibold text-[var(--color-text)]">
|
|
|
|
| 27 |
</p>
|
| 28 |
</div>
|
| 29 |
</div>
|
| 30 |
+
|
| 31 |
+
<div className="flex items-center gap-4">
|
| 32 |
+
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
|
| 33 |
+
<span className="w-2 h-2 rounded-full bg-[var(--color-success)]"></span>
|
| 34 |
+
<span className="text-sm font-medium text-[var(--color-success)]">
|
| 35 |
+
{userEmail}
|
| 36 |
+
</span>
|
| 37 |
+
</div>
|
| 38 |
+
<button
|
| 39 |
+
onClick={handleLogout}
|
| 40 |
+
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-accent)] transition-colors"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
>
|
| 42 |
+
登出
|
| 43 |
+
</button>
|
| 44 |
+
</div>
|
| 45 |
</div>
|
| 46 |
</header>
|
| 47 |
);
|
chatkit/frontend/src/components/Hero.tsx
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
interface HeroProps {
|
| 2 |
-
isAuthenticated: boolean;
|
| 3 |
-
onStartAnalysis: () => void;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
export function Hero({ isAuthenticated, onStartAnalysis }: HeroProps) {
|
| 7 |
-
return (
|
| 8 |
-
<section className="relative pt-32 pb-20 px-6 overflow-hidden">
|
| 9 |
-
{/* Decorative elements */}
|
| 10 |
-
<div className="absolute top-20 left-10 w-72 h-72 rounded-full bg-gradient-to-br from-[var(--color-accent)]/20 to-transparent blur-3xl animate-float"></div>
|
| 11 |
-
<div className="absolute bottom-0 right-10 w-96 h-96 rounded-full bg-gradient-to-br from-[var(--color-success)]/15 to-transparent blur-3xl animate-float" style={{ animationDelay: '2s' }}></div>
|
| 12 |
-
|
| 13 |
-
<div className="max-w-5xl mx-auto text-center relative">
|
| 14 |
-
<div className="opacity-0 animate-fade-in-up stagger-1">
|
| 15 |
-
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-sm font-medium mb-8">
|
| 16 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 17 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 18 |
-
</svg>
|
| 19 |
-
由 OpenAI ChatKit 提供技術支援
|
| 20 |
-
</span>
|
| 21 |
-
</div>
|
| 22 |
-
|
| 23 |
-
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-6 opacity-0 animate-fade-in-up stagger-2">
|
| 24 |
-
<span className="text-[var(--color-text)]">將考試轉化為</span>
|
| 25 |
-
<br />
|
| 26 |
-
<span className="text-gradient">教學洞察</span>
|
| 27 |
-
</h1>
|
| 28 |
-
|
| 29 |
-
<p className="text-xl text-[var(--color-text-muted)] max-w-2xl mx-auto mb-10 opacity-0 animate-fade-in-up stagger-3">
|
| 30 |
-
上傳您的 Google 表單回應,讓 AI 分析學生表現、生成個人化解釋並建立同儕學習小組——只需幾秒鐘。
|
| 31 |
-
</p>
|
| 32 |
-
|
| 33 |
-
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 opacity-0 animate-fade-in-up stagger-4">
|
| 34 |
-
{isAuthenticated ? (
|
| 35 |
-
<button onClick={onStartAnalysis} className="btn btn-accent text-lg px-8 py-4">
|
| 36 |
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 37 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
| 38 |
-
</svg>
|
| 39 |
-
開始分析
|
| 40 |
-
</button>
|
| 41 |
-
) : (
|
| 42 |
-
<a href="#connect" className="btn btn-primary text-lg px-8 py-4">
|
| 43 |
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
| 44 |
-
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
| 45 |
-
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
| 46 |
-
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 47 |
-
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 48 |
-
</svg>
|
| 49 |
-
連接 Google 帳號
|
| 50 |
-
</a>
|
| 51 |
-
)}
|
| 52 |
-
<a href="#features" className="btn btn-outline text-lg px-8 py-4">
|
| 53 |
-
查看使用方式
|
| 54 |
-
</a>
|
| 55 |
-
</div>
|
| 56 |
-
|
| 57 |
-
{/* Stats */}
|
| 58 |
-
<div className="mt-20 grid grid-cols-3 gap-8 max-w-2xl mx-auto opacity-0 animate-fade-in-up stagger-5">
|
| 59 |
-
{[
|
| 60 |
-
{ value: "5分鐘", label: "平均分析時間" },
|
| 61 |
-
{ value: "98%", label: "教師滿意度" },
|
| 62 |
-
{ value: "40+", label: "支援的題型" },
|
| 63 |
-
].map((stat, i) => (
|
| 64 |
-
<div key={i} className="text-center">
|
| 65 |
-
<div className="font-display text-3xl font-bold text-[var(--color-primary)]">
|
| 66 |
-
{stat.value}
|
| 67 |
-
</div>
|
| 68 |
-
<div className="text-sm text-[var(--color-text-muted)]">{stat.label}</div>
|
| 69 |
-
</div>
|
| 70 |
-
))}
|
| 71 |
-
</div>
|
| 72 |
-
</div>
|
| 73 |
-
</section>
|
| 74 |
-
);
|
| 75 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/LoginForm.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { apiPost, setToken } from "../lib/api";
|
| 3 |
+
import { CLASSLENS_ICON } from "../lib/icon";
|
| 4 |
+
|
| 5 |
+
interface User {
|
| 6 |
+
id: number;
|
| 7 |
+
email: string;
|
| 8 |
+
display_name: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface AuthResponse {
|
| 12 |
+
token: string;
|
| 13 |
+
user: User;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface LoginFormProps {
|
| 17 |
+
onLogin: (user: User) => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function LoginForm({ onLogin }: LoginFormProps) {
|
| 21 |
+
const [mode, setMode] = useState<"login" | "register">("login");
|
| 22 |
+
const [email, setEmail] = useState("");
|
| 23 |
+
const [password, setPassword] = useState("");
|
| 24 |
+
const [error, setError] = useState("");
|
| 25 |
+
const [loading, setLoading] = useState(false);
|
| 26 |
+
|
| 27 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 28 |
+
e.preventDefault();
|
| 29 |
+
setError("");
|
| 30 |
+
setLoading(true);
|
| 31 |
+
|
| 32 |
+
try {
|
| 33 |
+
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register";
|
| 34 |
+
const result = await apiPost<AuthResponse>(endpoint, { email, password });
|
| 35 |
+
setToken(result.token);
|
| 36 |
+
onLogin(result.user);
|
| 37 |
+
} catch (err: unknown) {
|
| 38 |
+
setError(err instanceof Error ? err.message : "An error occurred");
|
| 39 |
+
} finally {
|
| 40 |
+
setLoading(false);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f1419] via-[#1a2332] to-[#0f1419]">
|
| 46 |
+
<div className="relative">
|
| 47 |
+
<div className="absolute -top-20 -left-20 w-40 h-40 bg-[var(--color-primary)]/20 rounded-full blur-3xl" />
|
| 48 |
+
<div className="absolute -bottom-20 -right-20 w-40 h-40 bg-[var(--color-accent)]/20 rounded-full blur-3xl" />
|
| 49 |
+
|
| 50 |
+
<div className="relative bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl p-8 max-w-md w-full shadow-2xl">
|
| 51 |
+
<div className="text-center mb-8">
|
| 52 |
+
<img
|
| 53 |
+
src={CLASSLENS_ICON}
|
| 54 |
+
alt="ClassLens"
|
| 55 |
+
className="w-24 h-24 mx-auto mb-4 rounded-2xl shadow-lg"
|
| 56 |
+
/>
|
| 57 |
+
<h1 className="text-2xl font-bold text-[var(--color-text)] font-display">
|
| 58 |
+
ClassLens
|
| 59 |
+
</h1>
|
| 60 |
+
<p className="text-[var(--color-text-muted)] mt-2">
|
| 61 |
+
AI 驅動的考試分析
|
| 62 |
+
</p>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{/* Mode toggle */}
|
| 66 |
+
<div className="flex mb-6 bg-[var(--color-background)] rounded-xl p-1">
|
| 67 |
+
<button
|
| 68 |
+
type="button"
|
| 69 |
+
onClick={() => { setMode("login"); setError(""); }}
|
| 70 |
+
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-all ${
|
| 71 |
+
mode === "login"
|
| 72 |
+
? "bg-[var(--color-primary)] text-white shadow"
|
| 73 |
+
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
| 74 |
+
}`}
|
| 75 |
+
>
|
| 76 |
+
登入
|
| 77 |
+
</button>
|
| 78 |
+
<button
|
| 79 |
+
type="button"
|
| 80 |
+
onClick={() => { setMode("register"); setError(""); }}
|
| 81 |
+
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-all ${
|
| 82 |
+
mode === "register"
|
| 83 |
+
? "bg-[var(--color-primary)] text-white shadow"
|
| 84 |
+
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
| 85 |
+
}`}
|
| 86 |
+
>
|
| 87 |
+
註冊
|
| 88 |
+
</button>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 92 |
+
<div>
|
| 93 |
+
<label className="block text-sm font-medium text-[var(--color-text-muted)] mb-2">
|
| 94 |
+
電子郵件
|
| 95 |
+
</label>
|
| 96 |
+
<input
|
| 97 |
+
type="email"
|
| 98 |
+
value={email}
|
| 99 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 100 |
+
placeholder="your@email.com"
|
| 101 |
+
required
|
| 102 |
+
className="input"
|
| 103 |
+
autoFocus
|
| 104 |
+
/>
|
| 105 |
+
</div>
|
| 106 |
+
<div>
|
| 107 |
+
<label className="block text-sm font-medium text-[var(--color-text-muted)] mb-2">
|
| 108 |
+
密碼
|
| 109 |
+
</label>
|
| 110 |
+
<input
|
| 111 |
+
type="password"
|
| 112 |
+
value={password}
|
| 113 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 114 |
+
placeholder="••••••••"
|
| 115 |
+
required
|
| 116 |
+
minLength={4}
|
| 117 |
+
className="input"
|
| 118 |
+
/>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{error && (
|
| 122 |
+
<p className="text-sm text-red-400 flex items-center gap-1">
|
| 123 |
+
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 124 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 125 |
+
</svg>
|
| 126 |
+
{error}
|
| 127 |
+
</p>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
<button
|
| 131 |
+
type="submit"
|
| 132 |
+
disabled={loading}
|
| 133 |
+
className="w-full py-3 px-4 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-accent)] text-white font-semibold rounded-xl hover:opacity-90 transition-opacity disabled:opacity-50"
|
| 134 |
+
>
|
| 135 |
+
{loading ? "..." : mode === "login" ? "登入" : "建立帳號"}
|
| 136 |
+
</button>
|
| 137 |
+
</form>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
);
|
| 142 |
+
}
|
chatkit/frontend/src/components/ModelSelector.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface ModelOption {
|
| 2 |
+
id: string;
|
| 3 |
+
name: string;
|
| 4 |
+
desc: string;
|
| 5 |
+
tag?: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const MODELS: ModelOption[] = [
|
| 9 |
+
{
|
| 10 |
+
id: "gpt-5.4",
|
| 11 |
+
name: "GPT-5.4",
|
| 12 |
+
desc: "最強模型,適合複雜專業分析",
|
| 13 |
+
tag: "推薦",
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
id: "gpt-5.4-pro",
|
| 17 |
+
name: "GPT-5.4 Pro",
|
| 18 |
+
desc: "更高算力,回答更精確(較慢)",
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
id: "gpt-5.3-chat-latest",
|
| 22 |
+
name: "GPT-5.3 Instant",
|
| 23 |
+
desc: "快速回應,適合圖片解析與日常任務",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
id: "gpt-5-mini",
|
| 27 |
+
name: "GPT-5 Mini",
|
| 28 |
+
desc: "快速省費,適合簡單任務",
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: "gpt-4o",
|
| 32 |
+
name: "GPT-4o",
|
| 33 |
+
desc: "穩定可靠,速度快成本低",
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
id: "gpt-4o-mini",
|
| 37 |
+
name: "GPT-4o Mini",
|
| 38 |
+
desc: "最便宜,適合大量資料或測試",
|
| 39 |
+
},
|
| 40 |
+
];
|
| 41 |
+
|
| 42 |
+
interface ModelSelectorProps {
|
| 43 |
+
value: string;
|
| 44 |
+
onChange: (model: string) => void;
|
| 45 |
+
label?: string;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export function ModelSelector({ value, onChange, label }: ModelSelectorProps) {
|
| 49 |
+
const selected = MODELS.find((m) => m.id === value) || MODELS[0];
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="flex items-center gap-3">
|
| 53 |
+
{label && (
|
| 54 |
+
<span className="text-xs text-[var(--color-text-muted)] whitespace-nowrap">{label}</span>
|
| 55 |
+
)}
|
| 56 |
+
<select
|
| 57 |
+
value={value}
|
| 58 |
+
onChange={(e) => onChange(e.target.value)}
|
| 59 |
+
className="input !w-auto !py-1.5 text-sm"
|
| 60 |
+
>
|
| 61 |
+
{MODELS.map((m) => (
|
| 62 |
+
<option key={m.id} value={m.id}>
|
| 63 |
+
{m.name} — {m.desc}{m.tag ? ` (${m.tag})` : ""}
|
| 64 |
+
</option>
|
| 65 |
+
))}
|
| 66 |
+
</select>
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
}
|
chatkit/frontend/src/components/ReasoningPanel.tsx
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
import { useState, useEffect, useRef } from "react";
|
| 2 |
-
|
| 3 |
-
interface ReasoningStep {
|
| 4 |
-
id: string;
|
| 5 |
-
type: "tool" | "result" | "error" | "thinking";
|
| 6 |
-
content: string;
|
| 7 |
-
timestamp: Date;
|
| 8 |
-
status: "running" | "completed";
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
interface ReasoningPanelProps {
|
| 12 |
-
sessionId?: string;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
export function ReasoningPanel({ sessionId = "default" }: ReasoningPanelProps) {
|
| 16 |
-
const [steps, setSteps] = useState<ReasoningStep[]>([]);
|
| 17 |
-
const [isStreaming, setIsStreaming] = useState(false);
|
| 18 |
-
const containerRef = useRef<HTMLDivElement>(null);
|
| 19 |
-
|
| 20 |
-
// Subscribe to status updates
|
| 21 |
-
useEffect(() => {
|
| 22 |
-
let eventSource: EventSource | null = null;
|
| 23 |
-
|
| 24 |
-
const connect = () => {
|
| 25 |
-
// Use relative URL for production, works with both local and HF Spaces
|
| 26 |
-
const apiBase = import.meta.env.VITE_API_BASE_URL || "";
|
| 27 |
-
eventSource = new EventSource(`${apiBase}/api/status/${sessionId}/stream`);
|
| 28 |
-
|
| 29 |
-
eventSource.onmessage = (event) => {
|
| 30 |
-
try {
|
| 31 |
-
const data = JSON.parse(event.data);
|
| 32 |
-
|
| 33 |
-
// Handle reasoning steps from backend
|
| 34 |
-
if (data.reasoning && data.reasoning.length > 0) {
|
| 35 |
-
setIsStreaming(true);
|
| 36 |
-
|
| 37 |
-
const newSteps: ReasoningStep[] = data.reasoning.map((step: any, index: number) => ({
|
| 38 |
-
id: `step-${index}-${step.timestamp}`,
|
| 39 |
-
type: step.type as ReasoningStep["type"],
|
| 40 |
-
content: step.content,
|
| 41 |
-
timestamp: new Date(step.timestamp),
|
| 42 |
-
status: step.status === "active" ? "running" : "completed",
|
| 43 |
-
}));
|
| 44 |
-
|
| 45 |
-
setSteps(newSteps);
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
if (data.completed_at) {
|
| 49 |
-
setIsStreaming(false);
|
| 50 |
-
}
|
| 51 |
-
} catch (e) {
|
| 52 |
-
console.error("Error parsing status:", e);
|
| 53 |
-
}
|
| 54 |
-
};
|
| 55 |
-
|
| 56 |
-
eventSource.onerror = () => {
|
| 57 |
-
eventSource?.close();
|
| 58 |
-
// Reconnect after 3 seconds
|
| 59 |
-
setTimeout(connect, 3000);
|
| 60 |
-
};
|
| 61 |
-
};
|
| 62 |
-
|
| 63 |
-
connect();
|
| 64 |
-
return () => eventSource?.close();
|
| 65 |
-
}, [sessionId]);
|
| 66 |
-
|
| 67 |
-
// Auto-scroll to bottom
|
| 68 |
-
useEffect(() => {
|
| 69 |
-
if (containerRef.current) {
|
| 70 |
-
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
| 71 |
-
}
|
| 72 |
-
}, [steps]);
|
| 73 |
-
|
| 74 |
-
const getIcon = (type: ReasoningStep["type"], status: string) => {
|
| 75 |
-
if (status === "running") {
|
| 76 |
-
return (
|
| 77 |
-
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
| 78 |
-
);
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
switch (type) {
|
| 82 |
-
case "tool":
|
| 83 |
-
return <span className="text-blue-400">🔧</span>;
|
| 84 |
-
case "result":
|
| 85 |
-
return <span className="text-green-400">✓</span>;
|
| 86 |
-
case "error":
|
| 87 |
-
return <span className="text-red-400">✗</span>;
|
| 88 |
-
case "thinking":
|
| 89 |
-
return <span className="text-purple-400">🧠</span>;
|
| 90 |
-
default:
|
| 91 |
-
return <span>•</span>;
|
| 92 |
-
}
|
| 93 |
-
};
|
| 94 |
-
|
| 95 |
-
const getColor = (type: ReasoningStep["type"], status: string) => {
|
| 96 |
-
if (status === "running") return "text-blue-300 bg-blue-500/10 border-blue-500/30";
|
| 97 |
-
|
| 98 |
-
switch (type) {
|
| 99 |
-
case "tool":
|
| 100 |
-
return "text-blue-300 bg-gray-800/50 border-gray-600";
|
| 101 |
-
case "result":
|
| 102 |
-
return "text-green-300 bg-green-500/10 border-green-500/30";
|
| 103 |
-
case "error":
|
| 104 |
-
return "text-red-300 bg-red-500/10 border-red-500/30";
|
| 105 |
-
case "thinking":
|
| 106 |
-
return "text-purple-300 bg-purple-500/10 border-purple-500/30";
|
| 107 |
-
default:
|
| 108 |
-
return "text-gray-300 bg-gray-800/50 border-gray-600";
|
| 109 |
-
}
|
| 110 |
-
};
|
| 111 |
-
|
| 112 |
-
return (
|
| 113 |
-
<div className="bg-gray-900 rounded-xl border border-gray-700 overflow-hidden h-full flex flex-col">
|
| 114 |
-
{/* Header */}
|
| 115 |
-
<div className="px-4 py-3 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-shrink-0">
|
| 116 |
-
<div className="flex items-center gap-2">
|
| 117 |
-
<span className="text-sm font-semibold text-white">🤖 Tool Calls</span>
|
| 118 |
-
{isStreaming && (
|
| 119 |
-
<span className="flex items-center gap-1 text-xs text-green-400">
|
| 120 |
-
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
| 121 |
-
Live
|
| 122 |
-
</span>
|
| 123 |
-
)}
|
| 124 |
-
</div>
|
| 125 |
-
{steps.length > 0 && (
|
| 126 |
-
<button
|
| 127 |
-
onClick={() => setSteps([])}
|
| 128 |
-
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
| 129 |
-
>
|
| 130 |
-
Clear
|
| 131 |
-
</button>
|
| 132 |
-
)}
|
| 133 |
-
</div>
|
| 134 |
-
|
| 135 |
-
{/* Tool call log */}
|
| 136 |
-
<div
|
| 137 |
-
ref={containerRef}
|
| 138 |
-
className="p-3 flex-1 overflow-y-auto font-mono text-sm"
|
| 139 |
-
>
|
| 140 |
-
{steps.length === 0 ? (
|
| 141 |
-
<div className="text-gray-500 text-center py-8">
|
| 142 |
-
<div className="text-2xl mb-2">🔧</div>
|
| 143 |
-
<p>Tool calls will appear here</p>
|
| 144 |
-
<p className="text-xs mt-2">When AI uses tools, you'll see them logged</p>
|
| 145 |
-
</div>
|
| 146 |
-
) : (
|
| 147 |
-
<div className="space-y-2">
|
| 148 |
-
{steps.map((step) => (
|
| 149 |
-
<div
|
| 150 |
-
key={step.id}
|
| 151 |
-
className={`flex items-start gap-2 p-2 rounded-lg border ${getColor(step.type, step.status)}`}
|
| 152 |
-
>
|
| 153 |
-
<span className="flex-shrink-0 mt-0.5">
|
| 154 |
-
{getIcon(step.type, step.status)}
|
| 155 |
-
</span>
|
| 156 |
-
<div className="flex-1 min-w-0">
|
| 157 |
-
<span className="block break-words whitespace-pre-wrap">
|
| 158 |
-
{step.content}
|
| 159 |
-
</span>
|
| 160 |
-
<span className="text-xs opacity-50 mt-1 block">
|
| 161 |
-
{step.timestamp.toLocaleTimeString()}
|
| 162 |
-
</span>
|
| 163 |
-
</div>
|
| 164 |
-
</div>
|
| 165 |
-
))}
|
| 166 |
-
|
| 167 |
-
{isStreaming && (
|
| 168 |
-
<div className="flex items-center gap-2 text-gray-500 mt-2 justify-center py-2">
|
| 169 |
-
<div className="w-3 h-3 border-2 border-gray-500 border-t-transparent rounded-full animate-spin" />
|
| 170 |
-
<span className="text-xs">Waiting for next action...</span>
|
| 171 |
-
</div>
|
| 172 |
-
)}
|
| 173 |
-
</div>
|
| 174 |
-
)}
|
| 175 |
-
</div>
|
| 176 |
-
</div>
|
| 177 |
-
);
|
| 178 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/SimpleChatPanel.tsx
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
import { ChatKit, useChatKit } from "@openai/chatkit-react";
|
| 2 |
-
|
| 3 |
-
const CHATKIT_API_URL = "/chatkit";
|
| 4 |
-
const CHATKIT_API_DOMAIN_KEY = "domain_pk_localhost_dev";
|
| 5 |
-
|
| 6 |
-
export function SimpleChatPanel() {
|
| 7 |
-
const chatkit = useChatKit({
|
| 8 |
-
api: { url: CHATKIT_API_URL, domainKey: CHATKIT_API_DOMAIN_KEY },
|
| 9 |
-
composer: {
|
| 10 |
-
attachments: { enabled: false },
|
| 11 |
-
},
|
| 12 |
-
});
|
| 13 |
-
|
| 14 |
-
return (
|
| 15 |
-
<div className="relative pb-8 flex h-[90vh] w-full rounded-2xl flex-col overflow-hidden bg-white shadow-sm transition-colors dark:bg-slate-900">
|
| 16 |
-
<ChatKit control={chatkit.control} className="block h-full w-full" />
|
| 17 |
-
</div>
|
| 18 |
-
);
|
| 19 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/StepIndicator.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface StepIndicatorProps {
|
| 2 |
+
currentStep: 1 | 2 | 3;
|
| 3 |
+
onStepClick?: (step: 1 | 2 | 3) => void;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
const steps = [
|
| 7 |
+
{ num: 1 as const, label: "上傳資料", icon: "📁" },
|
| 8 |
+
{ num: 2 as const, label: "編輯提示詞", icon: "✏️" },
|
| 9 |
+
{ num: 3 as const, label: "檢視報告", icon: "📊" },
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
export function StepIndicator({ currentStep, onStepClick }: StepIndicatorProps) {
|
| 13 |
+
return (
|
| 14 |
+
<div className="flex items-center justify-center gap-2 py-6 px-4">
|
| 15 |
+
{steps.map((step, idx) => (
|
| 16 |
+
<div key={step.num} className="flex items-center">
|
| 17 |
+
<button
|
| 18 |
+
onClick={() => onStepClick?.(step.num)}
|
| 19 |
+
disabled={step.num > currentStep}
|
| 20 |
+
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
| 21 |
+
step.num === currentStep
|
| 22 |
+
? "bg-[var(--color-primary)] text-white shadow-lg"
|
| 23 |
+
: step.num < currentStep
|
| 24 |
+
? "bg-[var(--color-success)]/20 text-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/30"
|
| 25 |
+
: "bg-[var(--color-border)]/30 text-[var(--color-text-muted)] cursor-not-allowed"
|
| 26 |
+
}`}
|
| 27 |
+
>
|
| 28 |
+
<span>{step.num < currentStep ? "✓" : step.icon}</span>
|
| 29 |
+
<span className="hidden sm:inline">{step.label}</span>
|
| 30 |
+
<span className="sm:hidden">{step.num}</span>
|
| 31 |
+
</button>
|
| 32 |
+
{idx < steps.length - 1 && (
|
| 33 |
+
<div className={`w-8 h-0.5 mx-1 ${
|
| 34 |
+
step.num < currentStep ? "bg-[var(--color-success)]" : "bg-[var(--color-border)]"
|
| 35 |
+
}`} />
|
| 36 |
+
)}
|
| 37 |
+
</div>
|
| 38 |
+
))}
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
chatkit/frontend/src/components/WorkflowStatus.tsx
DELETED
|
@@ -1,281 +0,0 @@
|
|
| 1 |
-
import { useState, useEffect } from "react";
|
| 2 |
-
|
| 3 |
-
interface StepData {
|
| 4 |
-
id: string;
|
| 5 |
-
status: "pending" | "active" | "completed" | "error";
|
| 6 |
-
detail?: string;
|
| 7 |
-
timestamp?: string;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
interface StatusResponse {
|
| 11 |
-
current_step: string | null;
|
| 12 |
-
steps: StepData[];
|
| 13 |
-
started_at: string | null;
|
| 14 |
-
completed_at: string | null;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
interface WorkflowStep {
|
| 18 |
-
id: string;
|
| 19 |
-
label: string;
|
| 20 |
-
icon: JSX.Element;
|
| 21 |
-
status: "pending" | "active" | "completed" | "error";
|
| 22 |
-
detail?: string;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
interface WorkflowStatusProps {
|
| 26 |
-
sessionId?: string;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
const STEP_CONFIG: { id: string; label: string; icon: JSX.Element; defaultDetail: string }[] = [
|
| 30 |
-
{
|
| 31 |
-
id: "fetch",
|
| 32 |
-
label: "Fetch Responses",
|
| 33 |
-
icon: (
|
| 34 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 35 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
| 36 |
-
</svg>
|
| 37 |
-
),
|
| 38 |
-
defaultDetail: "Getting data from Google Sheets",
|
| 39 |
-
},
|
| 40 |
-
{
|
| 41 |
-
id: "normalize",
|
| 42 |
-
label: "Parse Data",
|
| 43 |
-
icon: (
|
| 44 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 45 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
| 46 |
-
</svg>
|
| 47 |
-
),
|
| 48 |
-
defaultDetail: "Organizing questions and answers",
|
| 49 |
-
},
|
| 50 |
-
{
|
| 51 |
-
id: "grade",
|
| 52 |
-
label: "Grade Answers",
|
| 53 |
-
icon: (
|
| 54 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 55 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 56 |
-
</svg>
|
| 57 |
-
),
|
| 58 |
-
defaultDetail: "Comparing with correct answers",
|
| 59 |
-
},
|
| 60 |
-
{
|
| 61 |
-
id: "explain",
|
| 62 |
-
label: "Generate Explanations",
|
| 63 |
-
icon: (
|
| 64 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 65 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
| 66 |
-
</svg>
|
| 67 |
-
),
|
| 68 |
-
defaultDetail: "Explaining wrong answers",
|
| 69 |
-
},
|
| 70 |
-
{
|
| 71 |
-
id: "group",
|
| 72 |
-
label: "Create Peer Groups",
|
| 73 |
-
icon: (
|
| 74 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 75 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 76 |
-
</svg>
|
| 77 |
-
),
|
| 78 |
-
defaultDetail: "Matching helpers with learners",
|
| 79 |
-
},
|
| 80 |
-
{
|
| 81 |
-
id: "report",
|
| 82 |
-
label: "Generate Report",
|
| 83 |
-
icon: (
|
| 84 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 85 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 86 |
-
</svg>
|
| 87 |
-
),
|
| 88 |
-
defaultDetail: "Creating comprehensive report",
|
| 89 |
-
},
|
| 90 |
-
{
|
| 91 |
-
id: "email",
|
| 92 |
-
label: "Send Email",
|
| 93 |
-
icon: (
|
| 94 |
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 95 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
| 96 |
-
</svg>
|
| 97 |
-
),
|
| 98 |
-
defaultDetail: "Delivering report to inbox",
|
| 99 |
-
},
|
| 100 |
-
];
|
| 101 |
-
|
| 102 |
-
export function WorkflowStatus({ sessionId = "default" }: WorkflowStatusProps) {
|
| 103 |
-
const [steps, setSteps] = useState<WorkflowStep[]>(
|
| 104 |
-
STEP_CONFIG.map(s => ({ ...s, status: "pending" as const, detail: s.defaultDetail }))
|
| 105 |
-
);
|
| 106 |
-
const [isConnected, setIsConnected] = useState(false);
|
| 107 |
-
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
| 108 |
-
|
| 109 |
-
useEffect(() => {
|
| 110 |
-
let eventSource: EventSource | null = null;
|
| 111 |
-
let retryCount = 0;
|
| 112 |
-
const maxRetries = 3;
|
| 113 |
-
|
| 114 |
-
const connect = () => {
|
| 115 |
-
eventSource = new EventSource(`/api/status/${sessionId}/stream`);
|
| 116 |
-
|
| 117 |
-
eventSource.onopen = () => {
|
| 118 |
-
setIsConnected(true);
|
| 119 |
-
retryCount = 0;
|
| 120 |
-
};
|
| 121 |
-
|
| 122 |
-
eventSource.onmessage = (event) => {
|
| 123 |
-
try {
|
| 124 |
-
const data: StatusResponse = JSON.parse(event.data);
|
| 125 |
-
|
| 126 |
-
// Check if there's any activity
|
| 127 |
-
const hasActivity = data.steps && data.steps.length > 0;
|
| 128 |
-
setIsAnalyzing(hasActivity && !data.completed_at);
|
| 129 |
-
|
| 130 |
-
// Update steps based on server data
|
| 131 |
-
setSteps(prevSteps => {
|
| 132 |
-
return prevSteps.map(step => {
|
| 133 |
-
const serverStep = data.steps?.find(s => s.id === step.id);
|
| 134 |
-
if (serverStep) {
|
| 135 |
-
return {
|
| 136 |
-
...step,
|
| 137 |
-
status: serverStep.status as WorkflowStep["status"],
|
| 138 |
-
detail: serverStep.detail || step.detail,
|
| 139 |
-
};
|
| 140 |
-
}
|
| 141 |
-
return step;
|
| 142 |
-
});
|
| 143 |
-
});
|
| 144 |
-
} catch (e) {
|
| 145 |
-
console.error("Error parsing status:", e);
|
| 146 |
-
}
|
| 147 |
-
};
|
| 148 |
-
|
| 149 |
-
eventSource.onerror = () => {
|
| 150 |
-
setIsConnected(false);
|
| 151 |
-
eventSource?.close();
|
| 152 |
-
|
| 153 |
-
// Retry connection
|
| 154 |
-
if (retryCount < maxRetries) {
|
| 155 |
-
retryCount++;
|
| 156 |
-
setTimeout(connect, 2000 * retryCount);
|
| 157 |
-
}
|
| 158 |
-
};
|
| 159 |
-
};
|
| 160 |
-
|
| 161 |
-
connect();
|
| 162 |
-
|
| 163 |
-
return () => {
|
| 164 |
-
eventSource?.close();
|
| 165 |
-
};
|
| 166 |
-
}, [sessionId]);
|
| 167 |
-
|
| 168 |
-
const getStatusColor = (status: WorkflowStep["status"]) => {
|
| 169 |
-
switch (status) {
|
| 170 |
-
case "completed": return "bg-[var(--color-success)] text-white";
|
| 171 |
-
case "active": return "bg-[var(--color-accent)] text-white animate-pulse";
|
| 172 |
-
case "error": return "bg-red-500 text-white";
|
| 173 |
-
default: return "bg-[var(--color-border)] text-[var(--color-text-muted)]";
|
| 174 |
-
}
|
| 175 |
-
};
|
| 176 |
-
|
| 177 |
-
const getLineColor = (status: WorkflowStep["status"]) => {
|
| 178 |
-
switch (status) {
|
| 179 |
-
case "completed": return "bg-[var(--color-success)]";
|
| 180 |
-
case "active": return "bg-[var(--color-accent)]";
|
| 181 |
-
default: return "bg-[var(--color-border)]";
|
| 182 |
-
}
|
| 183 |
-
};
|
| 184 |
-
|
| 185 |
-
const activeStep = steps.find(s => s.status === "active");
|
| 186 |
-
const completedCount = steps.filter(s => s.status === "completed").length;
|
| 187 |
-
|
| 188 |
-
return (
|
| 189 |
-
<div className="card p-4">
|
| 190 |
-
{/* Header */}
|
| 191 |
-
<div className="flex items-center justify-between mb-4">
|
| 192 |
-
<div className="flex items-center gap-2">
|
| 193 |
-
<div className={`w-2 h-2 rounded-full ${isAnalyzing ? 'bg-[var(--color-accent)] animate-pulse' : isConnected ? 'bg-[var(--color-success)]' : 'bg-[var(--color-border)]'}`} />
|
| 194 |
-
<h3 className="font-display text-sm font-semibold text-[var(--color-text)]">
|
| 195 |
-
{isAnalyzing ? "AI Agent Working..." : "Agent Status"}
|
| 196 |
-
</h3>
|
| 197 |
-
</div>
|
| 198 |
-
{completedCount > 0 && (
|
| 199 |
-
<span className="text-xs text-[var(--color-text-muted)]">
|
| 200 |
-
{completedCount}/{steps.length}
|
| 201 |
-
</span>
|
| 202 |
-
)}
|
| 203 |
-
</div>
|
| 204 |
-
|
| 205 |
-
{/* Active step highlight */}
|
| 206 |
-
{activeStep && (
|
| 207 |
-
<div className="mb-4 p-3 rounded-lg bg-[var(--color-accent)]/10 border border-[var(--color-accent)]/20">
|
| 208 |
-
<div className="flex items-center gap-2">
|
| 209 |
-
<div className="w-5 h-5 rounded-full bg-[var(--color-accent)] flex items-center justify-center animate-pulse">
|
| 210 |
-
<div className="w-2 h-2 bg-white rounded-full" />
|
| 211 |
-
</div>
|
| 212 |
-
<div>
|
| 213 |
-
<div className="text-sm font-medium text-[var(--color-accent)]">
|
| 214 |
-
{activeStep.label}
|
| 215 |
-
</div>
|
| 216 |
-
<div className="text-xs text-[var(--color-text-muted)]">
|
| 217 |
-
{activeStep.detail}
|
| 218 |
-
</div>
|
| 219 |
-
</div>
|
| 220 |
-
</div>
|
| 221 |
-
</div>
|
| 222 |
-
)}
|
| 223 |
-
|
| 224 |
-
{/* Steps list */}
|
| 225 |
-
<div className="space-y-1">
|
| 226 |
-
{steps.map((step, index) => (
|
| 227 |
-
<div key={step.id} className="relative">
|
| 228 |
-
{/* Connector line */}
|
| 229 |
-
{index < steps.length - 1 && (
|
| 230 |
-
<div
|
| 231 |
-
className={`absolute left-[11px] top-[24px] w-0.5 h-4 transition-colors duration-300 ${getLineColor(step.status)}`}
|
| 232 |
-
/>
|
| 233 |
-
)}
|
| 234 |
-
|
| 235 |
-
<div className="flex items-center gap-3 py-1">
|
| 236 |
-
{/* Icon circle */}
|
| 237 |
-
<div
|
| 238 |
-
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-300 ${getStatusColor(step.status)}`}
|
| 239 |
-
>
|
| 240 |
-
{step.status === "completed" ? (
|
| 241 |
-
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 242 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
| 243 |
-
</svg>
|
| 244 |
-
) : step.status === "active" ? (
|
| 245 |
-
<div className="w-2 h-2 bg-white rounded-full" />
|
| 246 |
-
) : step.status === "error" ? (
|
| 247 |
-
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 248 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
|
| 249 |
-
</svg>
|
| 250 |
-
) : (
|
| 251 |
-
<span className="text-[10px]">{index + 1}</span>
|
| 252 |
-
)}
|
| 253 |
-
</div>
|
| 254 |
-
|
| 255 |
-
{/* Label */}
|
| 256 |
-
<div className={`text-sm transition-colors ${
|
| 257 |
-
step.status === "active"
|
| 258 |
-
? "font-medium text-[var(--color-accent)]"
|
| 259 |
-
: step.status === "completed"
|
| 260 |
-
? "text-[var(--color-success)]"
|
| 261 |
-
: step.status === "error"
|
| 262 |
-
? "text-red-500"
|
| 263 |
-
: "text-[var(--color-text-muted)]"
|
| 264 |
-
}`}>
|
| 265 |
-
{step.label}
|
| 266 |
-
</div>
|
| 267 |
-
</div>
|
| 268 |
-
</div>
|
| 269 |
-
))}
|
| 270 |
-
</div>
|
| 271 |
-
|
| 272 |
-
{/* Connection status */}
|
| 273 |
-
<div className="mt-4 pt-3 border-t border-[var(--color-border)]">
|
| 274 |
-
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
| 275 |
-
<div className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`} />
|
| 276 |
-
{isConnected ? "Live updates" : "Connecting..."}
|
| 277 |
-
</div>
|
| 278 |
-
</div>
|
| 279 |
-
</div>
|
| 280 |
-
);
|
| 281 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/components/step1/DataViewer.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface DataViewerProps {
|
| 2 |
+
parsedData: Record<string, unknown[]>;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
export function DataViewer({ parsedData }: DataViewerProps) {
|
| 6 |
+
const questions = parsedData.questions?.[0] as { structured_data: { questions: Array<{ number: number; text: string; type: string; options: string[] | null; points: number | null }> } } | undefined;
|
| 7 |
+
const studentAnswers = parsedData.student_answers?.[0] as { structured_data: { students: Array<{ name: string; id: string; answers: Array<{ question_number: number; answer: string }> }> } } | undefined;
|
| 8 |
+
const teacherAnswers = parsedData.teacher_answers?.[0] as { structured_data: { answers: Array<{ question_number: number; correct_answer: string; explanation: string | null }> } } | undefined;
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="space-y-6">
|
| 12 |
+
{/* Questions */}
|
| 13 |
+
{questions && (
|
| 14 |
+
<div className="card p-6">
|
| 15 |
+
<h3 className="font-display text-lg font-semibold text-[var(--color-text)] mb-4">
|
| 16 |
+
📝 考試題目 ({questions.structured_data.questions?.length || 0} 題)
|
| 17 |
+
</h3>
|
| 18 |
+
<div className="overflow-x-auto">
|
| 19 |
+
<table className="w-full text-sm">
|
| 20 |
+
<thead>
|
| 21 |
+
<tr className="border-b border-[var(--color-border)]">
|
| 22 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">#</th>
|
| 23 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">題目</th>
|
| 24 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">類型</th>
|
| 25 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">分數</th>
|
| 26 |
+
</tr>
|
| 27 |
+
</thead>
|
| 28 |
+
<tbody>
|
| 29 |
+
{questions.structured_data.questions?.map((q) => (
|
| 30 |
+
<tr key={q.number} className="border-b border-[var(--color-border)]/50">
|
| 31 |
+
<td className="py-2 px-3 text-[var(--color-primary)] font-bold">{q.number}</td>
|
| 32 |
+
<td className="py-2 px-3 text-[var(--color-text)]">
|
| 33 |
+
<p>{q.text}</p>
|
| 34 |
+
{q.options && (
|
| 35 |
+
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
| 36 |
+
{q.options.join(" | ")}
|
| 37 |
+
</div>
|
| 38 |
+
)}
|
| 39 |
+
</td>
|
| 40 |
+
<td className="py-2 px-3">
|
| 41 |
+
<span className="badge badge-success">{q.type}</span>
|
| 42 |
+
</td>
|
| 43 |
+
<td className="py-2 px-3 text-[var(--color-text-muted)]">{q.points ?? "-"}</td>
|
| 44 |
+
</tr>
|
| 45 |
+
))}
|
| 46 |
+
</tbody>
|
| 47 |
+
</table>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
)}
|
| 51 |
+
|
| 52 |
+
{/* Student Answers */}
|
| 53 |
+
{studentAnswers && (
|
| 54 |
+
<div className="card p-6">
|
| 55 |
+
<h3 className="font-display text-lg font-semibold text-[var(--color-text)] mb-4">
|
| 56 |
+
👨🎓 學生答案 ({studentAnswers.structured_data.students?.length || 0} 位學生)
|
| 57 |
+
</h3>
|
| 58 |
+
<div className="overflow-x-auto">
|
| 59 |
+
<table className="w-full text-sm">
|
| 60 |
+
<thead>
|
| 61 |
+
<tr className="border-b border-[var(--color-border)]">
|
| 62 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">學生</th>
|
| 63 |
+
{questions?.structured_data.questions?.map((q) => (
|
| 64 |
+
<th key={q.number} className="text-center py-2 px-3 text-[var(--color-text-muted)]">Q{q.number}</th>
|
| 65 |
+
))}
|
| 66 |
+
</tr>
|
| 67 |
+
</thead>
|
| 68 |
+
<tbody>
|
| 69 |
+
{studentAnswers.structured_data.students?.map((s) => (
|
| 70 |
+
<tr key={s.name || s.id} className="border-b border-[var(--color-border)]/50">
|
| 71 |
+
<td className="py-2 px-3 text-[var(--color-text)] font-medium">{s.name || s.id}</td>
|
| 72 |
+
{(questions?.structured_data.questions || s.answers)?.map((_, i) => {
|
| 73 |
+
const ans = s.answers?.find((a) => a.question_number === i + 1);
|
| 74 |
+
return (
|
| 75 |
+
<td key={i} className="text-center py-2 px-3 text-[var(--color-text-muted)]">
|
| 76 |
+
{ans?.answer ?? "-"}
|
| 77 |
+
</td>
|
| 78 |
+
);
|
| 79 |
+
})}
|
| 80 |
+
</tr>
|
| 81 |
+
))}
|
| 82 |
+
</tbody>
|
| 83 |
+
</table>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
|
| 88 |
+
{/* Teacher Answers */}
|
| 89 |
+
{teacherAnswers && (
|
| 90 |
+
<div className="card p-6">
|
| 91 |
+
<h3 className="font-display text-lg font-semibold text-[var(--color-text)] mb-4">
|
| 92 |
+
✅ 標準答案
|
| 93 |
+
</h3>
|
| 94 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
| 95 |
+
{teacherAnswers.structured_data.answers?.map((a) => (
|
| 96 |
+
<div key={a.question_number} className="bg-[var(--color-background)] rounded-lg p-3">
|
| 97 |
+
<span className="text-xs text-[var(--color-text-muted)]">Q{a.question_number}</span>
|
| 98 |
+
<p className="text-[var(--color-success)] font-semibold">{a.correct_answer}</p>
|
| 99 |
+
</div>
|
| 100 |
+
))}
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
+
{!questions && !studentAnswers && !teacherAnswers && (
|
| 106 |
+
<div className="text-center py-12 text-[var(--color-text-muted)]">
|
| 107 |
+
尚未有解析資料
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
}
|
chatkit/frontend/src/components/step1/FileUploadPanel.tsx
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from "react";
|
| 2 |
+
import { apiUpload } from "../../lib/api";
|
| 3 |
+
import { ModelSelector } from "../ModelSelector";
|
| 4 |
+
|
| 5 |
+
interface FileUploadPanelProps {
|
| 6 |
+
sessionId: number;
|
| 7 |
+
parsedData: Record<string, unknown>;
|
| 8 |
+
onParsedDataUpdate: (dataType: string, data: unknown) => void;
|
| 9 |
+
onGoToStep2: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface UploadZone {
|
| 13 |
+
type: "questions" | "student_answers" | "teacher_answers";
|
| 14 |
+
label: string;
|
| 15 |
+
description: string;
|
| 16 |
+
placeholder: string;
|
| 17 |
+
icon: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const zones: UploadZone[] = [
|
| 21 |
+
{
|
| 22 |
+
type: "questions",
|
| 23 |
+
label: "考試題目",
|
| 24 |
+
description: "上傳考試試卷(任意格式)",
|
| 25 |
+
placeholder: "例如:國中三年級數學期中考,共 20 題選擇題和 5 題計算題",
|
| 26 |
+
icon: "📝",
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
type: "student_answers",
|
| 30 |
+
label: "學生答案",
|
| 31 |
+
description: "上傳學生作答資料(任意格式)",
|
| 32 |
+
placeholder: "例如:35 位學生的答案卷,手寫掃描",
|
| 33 |
+
icon: "👨🎓",
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
type: "teacher_answers",
|
| 37 |
+
label: "標準答案",
|
| 38 |
+
description: "上傳教師答案/解答(任意格式)",
|
| 39 |
+
placeholder: "例如:教師提供的標準答案與解題步驟",
|
| 40 |
+
icon: "✅",
|
| 41 |
+
},
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
export function FileUploadPanel({
|
| 45 |
+
sessionId,
|
| 46 |
+
parsedData,
|
| 47 |
+
onParsedDataUpdate,
|
| 48 |
+
onGoToStep2,
|
| 49 |
+
}: FileUploadPanelProps) {
|
| 50 |
+
const [files, setFiles] = useState<Record<string, File[]>>({
|
| 51 |
+
questions: [],
|
| 52 |
+
student_answers: [],
|
| 53 |
+
teacher_answers: [],
|
| 54 |
+
});
|
| 55 |
+
const [descriptions, setDescriptions] = useState<Record<string, string>>({
|
| 56 |
+
questions: "",
|
| 57 |
+
student_answers: "",
|
| 58 |
+
teacher_answers: "",
|
| 59 |
+
});
|
| 60 |
+
const [analyzing, setAnalyzing] = useState<Record<string, boolean>>({});
|
| 61 |
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
| 62 |
+
const [model, setModel] = useState("gpt-5.4");
|
| 63 |
+
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
| 64 |
+
|
| 65 |
+
const handleFileSelect = (type: string, selected: FileList | null) => {
|
| 66 |
+
if (!selected) return;
|
| 67 |
+
setFiles((prev) => ({ ...prev, [type]: Array.from(selected) }));
|
| 68 |
+
setErrors((prev) => ({ ...prev, [type]: "" }));
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const handleDrop = (type: string, e: React.DragEvent) => {
|
| 72 |
+
e.preventDefault();
|
| 73 |
+
handleFileSelect(type, e.dataTransfer.files);
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleAnalyze = async (type: string) => {
|
| 77 |
+
const typeFiles = files[type];
|
| 78 |
+
if (!typeFiles.length) return;
|
| 79 |
+
|
| 80 |
+
setAnalyzing((prev) => ({ ...prev, [type]: true }));
|
| 81 |
+
setErrors((prev) => ({ ...prev, [type]: "" }));
|
| 82 |
+
|
| 83 |
+
try {
|
| 84 |
+
const formData = new FormData();
|
| 85 |
+
formData.append("data_type", type);
|
| 86 |
+
formData.append("description", descriptions[type] || "");
|
| 87 |
+
formData.append("model", model);
|
| 88 |
+
for (const f of typeFiles) {
|
| 89 |
+
formData.append("files", f);
|
| 90 |
+
}
|
| 91 |
+
const res = await apiUpload<{ data: unknown }>(`/api/sessions/${sessionId}/upload`, formData);
|
| 92 |
+
onParsedDataUpdate(type, res.data);
|
| 93 |
+
} catch (err: unknown) {
|
| 94 |
+
setErrors((prev) => ({
|
| 95 |
+
...prev,
|
| 96 |
+
[type]: err instanceof Error ? err.message : "Analysis failed",
|
| 97 |
+
}));
|
| 98 |
+
} finally {
|
| 99 |
+
setAnalyzing((prev) => ({ ...prev, [type]: false }));
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const hasAnyParsedData = Object.keys(parsedData).length > 0;
|
| 104 |
+
const isAnyAnalyzing = Object.values(analyzing).some(Boolean);
|
| 105 |
+
|
| 106 |
+
return (
|
| 107 |
+
<div className="space-y-6">
|
| 108 |
+
{/* Model selector */}
|
| 109 |
+
<div className="flex justify-end">
|
| 110 |
+
<ModelSelector value={model} onChange={setModel} label="解析模型:" />
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Info note */}
|
| 114 |
+
<p className="text-xs text-[var(--color-text-muted)] text-center">
|
| 115 |
+
支援任意格式檔案(PDF、圖片、文字檔等)。若檔案中包含圖片、圖表或示意圖,AI 會自動將其轉換為文字描述,以確保後續分析完整準確。
|
| 116 |
+
</p>
|
| 117 |
+
|
| 118 |
+
{/* Upload zones */}
|
| 119 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 120 |
+
{zones.map((zone) => {
|
| 121 |
+
const zoneData = parsedData[zone.type] as Record<string, unknown> | undefined;
|
| 122 |
+
const isAnalyzing = analyzing[zone.type];
|
| 123 |
+
const hasFiles = files[zone.type].length > 0;
|
| 124 |
+
const hasParsed = !!zoneData;
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<div
|
| 128 |
+
key={zone.type}
|
| 129 |
+
className={`card p-6 transition-all ${hasParsed ? "border-[var(--color-success)]/50" : ""}`}
|
| 130 |
+
onDragOver={(e) => e.preventDefault()}
|
| 131 |
+
onDrop={(e) => handleDrop(zone.type, e)}
|
| 132 |
+
>
|
| 133 |
+
<div className="text-center mb-3">
|
| 134 |
+
<span className="text-3xl">{zone.icon}</span>
|
| 135 |
+
<h3 className="font-display text-lg font-semibold text-[var(--color-text)] mt-2">
|
| 136 |
+
{zone.label}
|
| 137 |
+
</h3>
|
| 138 |
+
<p className="text-sm text-[var(--color-text-muted)]">{zone.description}</p>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{/* Per-zone description */}
|
| 142 |
+
<textarea
|
| 143 |
+
value={descriptions[zone.type] || ""}
|
| 144 |
+
onChange={(e) =>
|
| 145 |
+
setDescriptions((prev) => ({ ...prev, [zone.type]: e.target.value }))
|
| 146 |
+
}
|
| 147 |
+
placeholder={zone.placeholder}
|
| 148 |
+
className="w-full mb-3 px-3 py-2 text-xs bg-[var(--color-surface)] border border-[var(--color-border)] rounded-lg text-[var(--color-text)] placeholder-[var(--color-text-muted)] resize-none focus:outline-none focus:border-[var(--color-primary)] transition-colors"
|
| 149 |
+
rows={2}
|
| 150 |
+
/>
|
| 151 |
+
|
| 152 |
+
<input
|
| 153 |
+
ref={(el) => { fileInputRefs.current[zone.type] = el; }}
|
| 154 |
+
type="file"
|
| 155 |
+
multiple
|
| 156 |
+
className="hidden"
|
| 157 |
+
onChange={(e) => handleFileSelect(zone.type, e.target.files)}
|
| 158 |
+
/>
|
| 159 |
+
|
| 160 |
+
<button
|
| 161 |
+
onClick={() => fileInputRefs.current[zone.type]?.click()}
|
| 162 |
+
className={`w-full py-6 border-2 border-dashed rounded-xl transition-all text-sm ${
|
| 163 |
+
hasFiles
|
| 164 |
+
? "border-[var(--color-primary)] bg-[var(--color-primary)]/5"
|
| 165 |
+
: "border-[var(--color-border)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5"
|
| 166 |
+
}`}
|
| 167 |
+
>
|
| 168 |
+
{hasFiles ? (
|
| 169 |
+
<div className="space-y-1">
|
| 170 |
+
{files[zone.type].map((f, i) => (
|
| 171 |
+
<p key={i} className="text-[var(--color-text)] truncate px-2">
|
| 172 |
+
{f.name}
|
| 173 |
+
</p>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
) : (
|
| 177 |
+
<p className="text-[var(--color-text-muted)]">
|
| 178 |
+
點擊或拖放檔案至此
|
| 179 |
+
</p>
|
| 180 |
+
)}
|
| 181 |
+
</button>
|
| 182 |
+
|
| 183 |
+
{/* Analyze button per zone */}
|
| 184 |
+
<button
|
| 185 |
+
onClick={() => handleAnalyze(zone.type)}
|
| 186 |
+
disabled={!hasFiles || isAnalyzing}
|
| 187 |
+
className="w-full mt-3 btn btn-primary py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
| 188 |
+
>
|
| 189 |
+
{isAnalyzing ? (
|
| 190 |
+
<span className="flex items-center justify-center gap-2">
|
| 191 |
+
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
| 192 |
+
解析中...
|
| 193 |
+
</span>
|
| 194 |
+
) : hasParsed ? (
|
| 195 |
+
"重新解析"
|
| 196 |
+
) : (
|
| 197 |
+
"解析資料"
|
| 198 |
+
)}
|
| 199 |
+
</button>
|
| 200 |
+
|
| 201 |
+
{/* Parsed status + summary */}
|
| 202 |
+
{hasParsed && (
|
| 203 |
+
<div className="mt-3 p-3 bg-[var(--color-background)] rounded-lg">
|
| 204 |
+
<p className="text-xs text-[var(--color-success)] font-semibold mb-1">✓ 解析完成</p>
|
| 205 |
+
<ZoneSummary type={zone.type} data={zoneData} />
|
| 206 |
+
</div>
|
| 207 |
+
)}
|
| 208 |
+
|
| 209 |
+
{errors[zone.type] && (
|
| 210 |
+
<p className="text-sm text-red-400 mt-2 text-center">{errors[zone.type]}</p>
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
);
|
| 214 |
+
})}
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{/* Full parsed data display */}
|
| 218 |
+
{hasAnyParsedData && (
|
| 219 |
+
<div className="space-y-4">
|
| 220 |
+
<QuestionsTable data={parsedData.questions as Record<string, unknown> | undefined} />
|
| 221 |
+
<StudentAnswersTable data={parsedData.student_answers as Record<string, unknown> | undefined} />
|
| 222 |
+
<TeacherAnswersTable data={parsedData.teacher_answers as Record<string, unknown> | undefined} />
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
|
| 226 |
+
{/* Next step button */}
|
| 227 |
+
{hasAnyParsedData && !isAnyAnalyzing && (
|
| 228 |
+
<div className="text-center">
|
| 229 |
+
<button
|
| 230 |
+
onClick={onGoToStep2}
|
| 231 |
+
className="btn btn-primary text-lg px-8 py-4"
|
| 232 |
+
>
|
| 233 |
+
確認資料,前往下一步 →
|
| 234 |
+
</button>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/** Compact summary shown inside each zone card */
|
| 242 |
+
function ZoneSummary({ type, data }: { type: string; data: Record<string, unknown> }) {
|
| 243 |
+
if (type === "questions") {
|
| 244 |
+
const questions = (data as { questions?: unknown[] }).questions;
|
| 245 |
+
if (!questions) return null;
|
| 246 |
+
return (
|
| 247 |
+
<p className="text-xs text-[var(--color-text-muted)]">
|
| 248 |
+
共 {questions.length} 題
|
| 249 |
+
</p>
|
| 250 |
+
);
|
| 251 |
+
}
|
| 252 |
+
if (type === "student_answers") {
|
| 253 |
+
const students = (data as { students?: unknown[] }).students;
|
| 254 |
+
if (!students) return null;
|
| 255 |
+
return (
|
| 256 |
+
<p className="text-xs text-[var(--color-text-muted)]">
|
| 257 |
+
共 {students.length} 位學生
|
| 258 |
+
</p>
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
if (type === "teacher_answers") {
|
| 262 |
+
const answers = (data as { answers?: unknown[] }).answers;
|
| 263 |
+
if (!answers) return null;
|
| 264 |
+
return (
|
| 265 |
+
<p className="text-xs text-[var(--color-text-muted)]">
|
| 266 |
+
共 {answers.length} 題答案
|
| 267 |
+
</p>
|
| 268 |
+
);
|
| 269 |
+
}
|
| 270 |
+
return null;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/* ── Parsed data tables ── */
|
| 274 |
+
|
| 275 |
+
function QuestionsTable({ data }: { data?: Record<string, unknown> }) {
|
| 276 |
+
if (!data) return null;
|
| 277 |
+
const questions = (data as { questions?: Array<{ number: number; text: string; type?: string; options?: string[] | null; points?: number | null }> }).questions;
|
| 278 |
+
if (!questions?.length) return null;
|
| 279 |
+
|
| 280 |
+
return (
|
| 281 |
+
<div className="card p-5">
|
| 282 |
+
<h3 className="font-display text-base font-semibold text-[var(--color-text)] mb-3">
|
| 283 |
+
📝 考試題目({questions.length} 題)
|
| 284 |
+
</h3>
|
| 285 |
+
<div className="overflow-x-auto">
|
| 286 |
+
<table className="w-full text-sm">
|
| 287 |
+
<thead>
|
| 288 |
+
<tr className="border-b border-[var(--color-border)]">
|
| 289 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-12">#</th>
|
| 290 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">題目</th>
|
| 291 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-28">類型</th>
|
| 292 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-16">分數</th>
|
| 293 |
+
</tr>
|
| 294 |
+
</thead>
|
| 295 |
+
<tbody>
|
| 296 |
+
{questions.map((q, i) => (
|
| 297 |
+
<tr key={i} className="border-b border-[var(--color-border)]/50">
|
| 298 |
+
<td className="py-2 px-3 text-[var(--color-primary)] font-bold">{q.number}</td>
|
| 299 |
+
<td className="py-2 px-3 text-[var(--color-text)]">
|
| 300 |
+
<p>{q.text}</p>
|
| 301 |
+
{q.options && (
|
| 302 |
+
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
| 303 |
+
{q.options.join(" | ")}
|
| 304 |
+
</div>
|
| 305 |
+
)}
|
| 306 |
+
</td>
|
| 307 |
+
<td className="py-2 px-3">
|
| 308 |
+
{q.type && <span className="badge badge-success text-xs">{q.type}</span>}
|
| 309 |
+
</td>
|
| 310 |
+
<td className="py-2 px-3 text-[var(--color-text-muted)]">{q.points ?? "-"}</td>
|
| 311 |
+
</tr>
|
| 312 |
+
))}
|
| 313 |
+
</tbody>
|
| 314 |
+
</table>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function StudentAnswersTable({ data }: { data?: Record<string, unknown> }) {
|
| 321 |
+
if (!data) return null;
|
| 322 |
+
const students = (data as { students?: Array<{ name: string; id?: string; answers: Array<{ question_number: number; answer: string | null }> }> }).students;
|
| 323 |
+
if (!students?.length) return null;
|
| 324 |
+
|
| 325 |
+
// Collect all question numbers across students
|
| 326 |
+
const allQNums = Array.from(
|
| 327 |
+
new Set(students.flatMap((s) => s.answers.map((a) => a.question_number)))
|
| 328 |
+
).sort((a, b) => a - b);
|
| 329 |
+
|
| 330 |
+
return (
|
| 331 |
+
<div className="card p-5">
|
| 332 |
+
<h3 className="font-display text-base font-semibold text-[var(--color-text)] mb-3">
|
| 333 |
+
👨🎓 學生答案({students.length} 位學生)
|
| 334 |
+
</h3>
|
| 335 |
+
<div className="overflow-x-auto">
|
| 336 |
+
<table className="w-full text-sm">
|
| 337 |
+
<thead>
|
| 338 |
+
<tr className="border-b border-[var(--color-border)]">
|
| 339 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)] sticky left-0 bg-[var(--color-surface)]">學生</th>
|
| 340 |
+
{allQNums.map((n) => (
|
| 341 |
+
<th key={n} className="text-center py-2 px-3 text-[var(--color-text-muted)] min-w-[60px]">Q{n}</th>
|
| 342 |
+
))}
|
| 343 |
+
</tr>
|
| 344 |
+
</thead>
|
| 345 |
+
<tbody>
|
| 346 |
+
{students.map((s, i) => (
|
| 347 |
+
<tr key={i} className="border-b border-[var(--color-border)]/50">
|
| 348 |
+
<td className="py-2 px-3 text-[var(--color-text)] font-medium sticky left-0 bg-[var(--color-surface)]">
|
| 349 |
+
{s.name || s.id || `學生 ${i + 1}`}
|
| 350 |
+
</td>
|
| 351 |
+
{allQNums.map((n) => {
|
| 352 |
+
const ans = s.answers?.find((a) => a.question_number === n);
|
| 353 |
+
return (
|
| 354 |
+
<td key={n} className="text-center py-2 px-3 text-[var(--color-text-muted)]">
|
| 355 |
+
{ans?.answer ?? "-"}
|
| 356 |
+
</td>
|
| 357 |
+
);
|
| 358 |
+
})}
|
| 359 |
+
</tr>
|
| 360 |
+
))}
|
| 361 |
+
</tbody>
|
| 362 |
+
</table>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function TeacherAnswersTable({ data }: { data?: Record<string, unknown> }) {
|
| 369 |
+
if (!data) return null;
|
| 370 |
+
const answers = (data as { answers?: Array<{ question_number: number; correct_answer: string; explanation?: string | null }> }).answers;
|
| 371 |
+
if (!answers?.length) return null;
|
| 372 |
+
|
| 373 |
+
return (
|
| 374 |
+
<div className="card p-5">
|
| 375 |
+
<h3 className="font-display text-base font-semibold text-[var(--color-text)] mb-3">
|
| 376 |
+
✅ 標準答案({answers.length} 題)
|
| 377 |
+
</h3>
|
| 378 |
+
<div className="overflow-x-auto">
|
| 379 |
+
<table className="w-full text-sm">
|
| 380 |
+
<thead>
|
| 381 |
+
<tr className="border-b border-[var(--color-border)]">
|
| 382 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-12">#</th>
|
| 383 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">正確答案</th>
|
| 384 |
+
<th className="text-left py-2 px-3 text-[var(--color-text-muted)]">解釋</th>
|
| 385 |
+
</tr>
|
| 386 |
+
</thead>
|
| 387 |
+
<tbody>
|
| 388 |
+
{answers.map((a, i) => (
|
| 389 |
+
<tr key={i} className="border-b border-[var(--color-border)]/50">
|
| 390 |
+
<td className="py-2 px-3 text-[var(--color-primary)] font-bold">Q{a.question_number}</td>
|
| 391 |
+
<td className="py-2 px-3 text-[var(--color-success)] font-semibold">{a.correct_answer}</td>
|
| 392 |
+
<td className="py-2 px-3 text-[var(--color-text-muted)]">{a.explanation || "-"}</td>
|
| 393 |
+
</tr>
|
| 394 |
+
))}
|
| 395 |
+
</tbody>
|
| 396 |
+
</table>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
);
|
| 400 |
+
}
|
chatkit/frontend/src/components/step2/ParsedDataSummary.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
|
| 3 |
+
interface ParsedDataSummaryProps {
|
| 4 |
+
parsedData: Record<string, unknown>;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function ParsedDataSummary({ parsedData }: ParsedDataSummaryProps) {
|
| 8 |
+
const [expanded, setExpanded] = useState(false);
|
| 9 |
+
|
| 10 |
+
const questions = parsedData.questions as { questions?: unknown[] } | undefined;
|
| 11 |
+
const students = parsedData.student_answers as { students?: unknown[] } | undefined;
|
| 12 |
+
const answers = parsedData.teacher_answers as { answers?: unknown[] } | undefined;
|
| 13 |
+
|
| 14 |
+
const qCount = questions?.questions?.length ?? 0;
|
| 15 |
+
const sCount = students?.students?.length ?? 0;
|
| 16 |
+
const aCount = answers?.answers?.length ?? 0;
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="card p-4">
|
| 20 |
+
<button
|
| 21 |
+
onClick={() => setExpanded(!expanded)}
|
| 22 |
+
className="w-full flex items-center justify-between text-left"
|
| 23 |
+
>
|
| 24 |
+
<div className="flex items-center gap-4">
|
| 25 |
+
<h3 className="font-display text-sm font-semibold text-[var(--color-text)]">
|
| 26 |
+
已解析資料
|
| 27 |
+
</h3>
|
| 28 |
+
<div className="flex gap-3 text-xs">
|
| 29 |
+
{qCount > 0 && <span className="badge badge-success">📝 {qCount} 題</span>}
|
| 30 |
+
{sCount > 0 && <span className="badge badge-success">👨🎓 {sCount} 位學生</span>}
|
| 31 |
+
{aCount > 0 && <span className="badge badge-success">✅ {aCount} 答案</span>}
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
<span className="text-[var(--color-text-muted)] text-lg">
|
| 35 |
+
{expanded ? "▲" : "▼"}
|
| 36 |
+
</span>
|
| 37 |
+
</button>
|
| 38 |
+
|
| 39 |
+
{expanded && (
|
| 40 |
+
<div className="mt-4 pt-4 border-t border-[var(--color-border)] text-sm max-h-64 overflow-y-auto">
|
| 41 |
+
<pre className="text-[var(--color-text-muted)] whitespace-pre-wrap break-words">
|
| 42 |
+
{JSON.stringify(parsedData, null, 2)}
|
| 43 |
+
</pre>
|
| 44 |
+
</div>
|
| 45 |
+
)}
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
chatkit/frontend/src/components/step2/PromptEditor.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { apiGet, apiPost } from "../../lib/api";
|
| 3 |
+
import { ModelSelector } from "../ModelSelector";
|
| 4 |
+
|
| 5 |
+
interface Prompt {
|
| 6 |
+
id: number;
|
| 7 |
+
name: string;
|
| 8 |
+
content: string;
|
| 9 |
+
user_id: number;
|
| 10 |
+
author_email?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface PromptEditorProps {
|
| 14 |
+
promptContent: string;
|
| 15 |
+
onPromptChange: (content: string) => void;
|
| 16 |
+
sessionId: number;
|
| 17 |
+
onRunSummary: (model: string) => void;
|
| 18 |
+
isGenerating: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function PromptEditor({
|
| 22 |
+
promptContent,
|
| 23 |
+
onPromptChange,
|
| 24 |
+
sessionId,
|
| 25 |
+
onRunSummary,
|
| 26 |
+
isGenerating,
|
| 27 |
+
}: PromptEditorProps) {
|
| 28 |
+
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
| 29 |
+
const [allPrompts, setAllPrompts] = useState<Prompt[]>([]);
|
| 30 |
+
const [saveName, setSaveName] = useState("");
|
| 31 |
+
const [showSave, setShowSave] = useState(false);
|
| 32 |
+
const [showOthers, setShowOthers] = useState(false);
|
| 33 |
+
const [saving, setSaving] = useState(false);
|
| 34 |
+
const [model, setModel] = useState("gpt-5.4");
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
loadPrompts();
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
const loadPrompts = async () => {
|
| 41 |
+
try {
|
| 42 |
+
const [mine, all] = await Promise.all([
|
| 43 |
+
apiGet<{ prompts: Prompt[] }>("/api/prompts"),
|
| 44 |
+
apiGet<{ prompts: Prompt[] }>("/api/prompts/all"),
|
| 45 |
+
]);
|
| 46 |
+
setPrompts(mine.prompts);
|
| 47 |
+
setAllPrompts(all.prompts);
|
| 48 |
+
} catch {}
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const handleSave = async () => {
|
| 52 |
+
if (!saveName.trim()) return;
|
| 53 |
+
setSaving(true);
|
| 54 |
+
try {
|
| 55 |
+
await apiPost("/api/prompts", { name: saveName, content: promptContent });
|
| 56 |
+
setSaveName("");
|
| 57 |
+
setShowSave(false);
|
| 58 |
+
await loadPrompts();
|
| 59 |
+
} catch {}
|
| 60 |
+
setSaving(false);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const handleLoadPrompt = (prompt: Prompt) => {
|
| 64 |
+
onPromptChange(prompt.content);
|
| 65 |
+
setShowOthers(false);
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<div className="space-y-4">
|
| 70 |
+
{/* Toolbar */}
|
| 71 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 72 |
+
{/* Load own prompts */}
|
| 73 |
+
{prompts.length > 0 && (
|
| 74 |
+
<select
|
| 75 |
+
onChange={(e) => {
|
| 76 |
+
const p = prompts.find((p) => p.id === Number(e.target.value));
|
| 77 |
+
if (p) onPromptChange(p.content);
|
| 78 |
+
}}
|
| 79 |
+
defaultValue=""
|
| 80 |
+
className="input !w-auto !py-2 text-sm"
|
| 81 |
+
>
|
| 82 |
+
<option value="" disabled>我的提示詞...</option>
|
| 83 |
+
{prompts.map((p) => (
|
| 84 |
+
<option key={p.id} value={p.id}>{p.name}</option>
|
| 85 |
+
))}
|
| 86 |
+
</select>
|
| 87 |
+
)}
|
| 88 |
+
|
| 89 |
+
{/* Load others' prompts */}
|
| 90 |
+
<button
|
| 91 |
+
onClick={() => setShowOthers(!showOthers)}
|
| 92 |
+
className="btn btn-outline text-sm !py-2"
|
| 93 |
+
>
|
| 94 |
+
瀏覽其他人的提示詞
|
| 95 |
+
</button>
|
| 96 |
+
|
| 97 |
+
{/* Save button */}
|
| 98 |
+
<button
|
| 99 |
+
onClick={() => setShowSave(!showSave)}
|
| 100 |
+
className="btn btn-outline text-sm !py-2"
|
| 101 |
+
>
|
| 102 |
+
儲存提示詞
|
| 103 |
+
</button>
|
| 104 |
+
|
| 105 |
+
{/* Model selector - right aligned */}
|
| 106 |
+
<div className="ml-auto">
|
| 107 |
+
<ModelSelector value={model} onChange={setModel} label="報告模型:" />
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Others' prompts dropdown */}
|
| 112 |
+
{showOthers && (
|
| 113 |
+
<div className="card p-4 max-h-48 overflow-y-auto">
|
| 114 |
+
<h4 className="text-sm font-semibold text-[var(--color-text-muted)] mb-2">所有提示詞</h4>
|
| 115 |
+
{allPrompts.length === 0 ? (
|
| 116 |
+
<p className="text-sm text-[var(--color-text-muted)]">目前沒有其他人的提示詞</p>
|
| 117 |
+
) : (
|
| 118 |
+
<div className="space-y-2">
|
| 119 |
+
{allPrompts.map((p) => (
|
| 120 |
+
<button
|
| 121 |
+
key={p.id}
|
| 122 |
+
onClick={() => handleLoadPrompt(p)}
|
| 123 |
+
className="w-full text-left p-2 rounded-lg hover:bg-[var(--color-primary)]/10 transition-colors"
|
| 124 |
+
>
|
| 125 |
+
<span className="text-sm text-[var(--color-text)]">{p.name}</span>
|
| 126 |
+
{p.author_email && (
|
| 127 |
+
<span className="text-xs text-[var(--color-text-muted)] ml-2">
|
| 128 |
+
by {p.author_email}
|
| 129 |
+
</span>
|
| 130 |
+
)}
|
| 131 |
+
</button>
|
| 132 |
+
))}
|
| 133 |
+
</div>
|
| 134 |
+
)}
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
|
| 138 |
+
{/* Save prompt form */}
|
| 139 |
+
{showSave && (
|
| 140 |
+
<div className="card p-4 flex items-center gap-3">
|
| 141 |
+
<input
|
| 142 |
+
type="text"
|
| 143 |
+
value={saveName}
|
| 144 |
+
onChange={(e) => setSaveName(e.target.value)}
|
| 145 |
+
placeholder="提示詞名稱..."
|
| 146 |
+
className="input !py-2 text-sm flex-1"
|
| 147 |
+
/>
|
| 148 |
+
<button
|
| 149 |
+
onClick={handleSave}
|
| 150 |
+
disabled={saving || !saveName.trim()}
|
| 151 |
+
className="btn btn-primary text-sm !py-2 disabled:opacity-50"
|
| 152 |
+
>
|
| 153 |
+
{saving ? "..." : "儲存"}
|
| 154 |
+
</button>
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
|
| 158 |
+
{/* Prompt textarea */}
|
| 159 |
+
<textarea
|
| 160 |
+
value={promptContent}
|
| 161 |
+
onChange={(e) => onPromptChange(e.target.value)}
|
| 162 |
+
rows={12}
|
| 163 |
+
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y"
|
| 164 |
+
placeholder="輸入分析提示詞...使用 {questions}, {student_answers}, {teacher_answers} 作為資料佔位符"
|
| 165 |
+
/>
|
| 166 |
+
|
| 167 |
+
{/* Run summary button */}
|
| 168 |
+
<div className="text-center">
|
| 169 |
+
<button
|
| 170 |
+
onClick={() => onRunSummary(model)}
|
| 171 |
+
disabled={isGenerating || !promptContent.trim()}
|
| 172 |
+
className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 173 |
+
>
|
| 174 |
+
{isGenerating ? (
|
| 175 |
+
<span className="flex items-center gap-2">
|
| 176 |
+
<span className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
| 177 |
+
生成報告中...
|
| 178 |
+
</span>
|
| 179 |
+
) : (
|
| 180 |
+
"執行摘要分析 →"
|
| 181 |
+
)}
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
chatkit/frontend/src/components/step3/ReportViewer.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef } from "react";
|
| 2 |
+
|
| 3 |
+
interface ReportViewerProps {
|
| 4 |
+
reportHtml: string;
|
| 5 |
+
onBack: () => void;
|
| 6 |
+
onExportPdf: () => void;
|
| 7 |
+
onExportPng: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function ReportViewer({ reportHtml, onBack, onExportPdf, onExportPng }: ReportViewerProps) {
|
| 11 |
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
| 12 |
+
|
| 13 |
+
const handleDownloadHtml = () => {
|
| 14 |
+
const blob = new Blob([reportHtml], { type: "text/html" });
|
| 15 |
+
const url = URL.createObjectURL(blob);
|
| 16 |
+
const a = document.createElement("a");
|
| 17 |
+
a.href = url;
|
| 18 |
+
a.download = "classlens-report.html";
|
| 19 |
+
a.click();
|
| 20 |
+
URL.revokeObjectURL(url);
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="space-y-4">
|
| 25 |
+
{/* Action bar */}
|
| 26 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 27 |
+
<button onClick={onBack} className="btn btn-outline text-sm">
|
| 28 |
+
← 返回修改提示詞
|
| 29 |
+
</button>
|
| 30 |
+
<div className="flex-1" />
|
| 31 |
+
<button onClick={handleDownloadHtml} className="btn btn-outline text-sm">
|
| 32 |
+
下載 HTML
|
| 33 |
+
</button>
|
| 34 |
+
<button onClick={onExportPdf} className="btn btn-outline text-sm">
|
| 35 |
+
匯出 PDF
|
| 36 |
+
</button>
|
| 37 |
+
<button onClick={onExportPng} className="btn btn-outline text-sm">
|
| 38 |
+
匯出 PNG
|
| 39 |
+
</button>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{/* Report iframe */}
|
| 43 |
+
<div className="card overflow-hidden" style={{ height: "75vh" }}>
|
| 44 |
+
<iframe
|
| 45 |
+
ref={iframeRef}
|
| 46 |
+
srcDoc={reportHtml}
|
| 47 |
+
title="Report"
|
| 48 |
+
className="w-full h-full border-0"
|
| 49 |
+
sandbox="allow-scripts allow-same-origin"
|
| 50 |
+
/>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
chatkit/frontend/src/index.css
CHANGED
|
@@ -291,39 +291,3 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 291 |
background-clip: text;
|
| 292 |
}
|
| 293 |
|
| 294 |
-
/* ChatKit customization - Force Traditional Chinese */
|
| 295 |
-
.chatkit-container,
|
| 296 |
-
[data-chatkit-composer] {
|
| 297 |
-
/* Force zh-TW locale */
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
/* Override ChatKit placeholder text to Traditional Chinese */
|
| 301 |
-
[data-chatkit-composer] textarea::placeholder,
|
| 302 |
-
[data-chatkit-composer] input::placeholder,
|
| 303 |
-
.chatkit-composer textarea::placeholder,
|
| 304 |
-
.chatkit-composer input::placeholder {
|
| 305 |
-
/* This may not work as ChatKit uses iframe */
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
/* ChatKit customization */
|
| 309 |
-
.chatkit-container {
|
| 310 |
-
--ck-color-primary: var(--color-primary);
|
| 311 |
-
--ck-color-background: var(--color-surface);
|
| 312 |
-
--ck-border-radius: 16px;
|
| 313 |
-
display: flex;
|
| 314 |
-
flex-direction: column;
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
/* Ensure ChatKit elements are visible */
|
| 318 |
-
.chatkit-container > * {
|
| 319 |
-
min-height: 0;
|
| 320 |
-
flex: 1;
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
/* Make sure the input area is always visible */
|
| 324 |
-
[data-chatkit-composer],
|
| 325 |
-
[class*="composer"],
|
| 326 |
-
[class*="Composer"] {
|
| 327 |
-
position: relative !important;
|
| 328 |
-
z-index: 10;
|
| 329 |
-
}
|
|
|
|
| 291 |
background-clip: text;
|
| 292 |
}
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { API_BASE_URL } from "./config";
|
| 2 |
+
|
| 3 |
+
const TOKEN_KEY = "classlens_token";
|
| 4 |
+
|
| 5 |
+
export function getToken(): string | null {
|
| 6 |
+
return localStorage.getItem(TOKEN_KEY);
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function setToken(token: string) {
|
| 10 |
+
localStorage.setItem(TOKEN_KEY, token);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function clearToken() {
|
| 14 |
+
localStorage.removeItem(TOKEN_KEY);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function authHeaders(): Record<string, string> {
|
| 18 |
+
const token = getToken();
|
| 19 |
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function handleResponse<T>(res: Response): Promise<T> {
|
| 23 |
+
if (res.status === 401) {
|
| 24 |
+
clearToken();
|
| 25 |
+
window.location.reload();
|
| 26 |
+
throw new Error("Unauthorized");
|
| 27 |
+
}
|
| 28 |
+
if (!res.ok) {
|
| 29 |
+
const text = await res.text();
|
| 30 |
+
let msg = text;
|
| 31 |
+
try {
|
| 32 |
+
const j = JSON.parse(text);
|
| 33 |
+
msg = j.detail || j.message || text;
|
| 34 |
+
} catch {}
|
| 35 |
+
throw new Error(msg);
|
| 36 |
+
}
|
| 37 |
+
return res.json();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export async function apiGet<T>(path: string): Promise<T> {
|
| 41 |
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
| 42 |
+
headers: { ...authHeaders() },
|
| 43 |
+
});
|
| 44 |
+
return handleResponse<T>(res);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
| 48 |
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
| 49 |
+
method: "POST",
|
| 50 |
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
| 51 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 52 |
+
});
|
| 53 |
+
return handleResponse<T>(res);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
| 57 |
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
| 58 |
+
method: "PUT",
|
| 59 |
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
| 60 |
+
body: JSON.stringify(body),
|
| 61 |
+
});
|
| 62 |
+
return handleResponse<T>(res);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export async function apiDelete(path: string): Promise<void> {
|
| 66 |
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
| 67 |
+
method: "DELETE",
|
| 68 |
+
headers: { ...authHeaders() },
|
| 69 |
+
});
|
| 70 |
+
if (res.status === 401) {
|
| 71 |
+
clearToken();
|
| 72 |
+
window.location.reload();
|
| 73 |
+
throw new Error("Unauthorized");
|
| 74 |
+
}
|
| 75 |
+
if (!res.ok) {
|
| 76 |
+
const text = await res.text();
|
| 77 |
+
throw new Error(text);
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export async function apiUpload<T>(path: string, formData: FormData): Promise<T> {
|
| 82 |
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
| 83 |
+
method: "POST",
|
| 84 |
+
headers: { ...authHeaders() },
|
| 85 |
+
body: formData,
|
| 86 |
+
});
|
| 87 |
+
return handleResponse<T>(res);
|
| 88 |
+
}
|
chatkit/frontend/src/lib/config.ts
CHANGED
|
@@ -3,20 +3,5 @@ const readEnvString = (value: unknown): string | undefined =>
|
|
| 3 |
? value.trim()
|
| 4 |
: undefined;
|
| 5 |
|
| 6 |
-
export const CHATKIT_API_URL =
|
| 7 |
-
readEnvString(import.meta.env.VITE_CHATKIT_API_URL) ?? "/chatkit";
|
| 8 |
-
|
| 9 |
export const API_BASE_URL =
|
| 10 |
readEnvString(import.meta.env.VITE_API_BASE_URL) ?? "";
|
| 11 |
-
|
| 12 |
-
/**
|
| 13 |
-
* ChatKit requires a domain key at runtime. Use the local fallback while
|
| 14 |
-
* developing, and register a production domain key for deployment:
|
| 15 |
-
* https://platform.openai.com/settings/organization/security/domain-allowlist
|
| 16 |
-
*/
|
| 17 |
-
export const CHATKIT_API_DOMAIN_KEY =
|
| 18 |
-
readEnvString(import.meta.env.VITE_CHATKIT_API_DOMAIN_KEY) ??
|
| 19 |
-
"domain_pk_localhost_dev";
|
| 20 |
-
|
| 21 |
-
export const GOOGLE_AUTH_URL = `${API_BASE_URL}/auth/start`;
|
| 22 |
-
export const AUTH_STATUS_URL = `${API_BASE_URL}/auth/status`;
|
|
|
|
| 3 |
? value.trim()
|
| 4 |
: undefined;
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
export const API_BASE_URL =
|
| 7 |
readEnvString(import.meta.env.VITE_API_BASE_URL) ?? "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/frontend/src/types/html2pdf.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare module "html2pdf.js" {
|
| 2 |
+
interface Html2PdfOptions {
|
| 3 |
+
margin?: number | number[];
|
| 4 |
+
filename?: string;
|
| 5 |
+
image?: { type?: string; quality?: number };
|
| 6 |
+
html2canvas?: { scale?: number; useCORS?: boolean };
|
| 7 |
+
jsPDF?: { unit?: string; format?: string; orientation?: string };
|
| 8 |
+
}
|
| 9 |
+
interface Html2PdfInstance {
|
| 10 |
+
set(options: Html2PdfOptions): Html2PdfInstance;
|
| 11 |
+
from(element: HTMLElement | string): Html2PdfInstance;
|
| 12 |
+
save(): Promise<void>;
|
| 13 |
+
toPdf(): Html2PdfInstance;
|
| 14 |
+
output(type: string): Promise<unknown>;
|
| 15 |
+
}
|
| 16 |
+
function html2pdf(): Html2PdfInstance;
|
| 17 |
+
export default html2pdf;
|
| 18 |
+
}
|
chatkit/frontend/vite.config.ts
CHANGED
|
@@ -2,24 +2,15 @@ import * as path from "node:path";
|
|
| 2 |
import { defineConfig } from "vite";
|
| 3 |
import react from "@vitejs/plugin-react-swc";
|
| 4 |
|
| 5 |
-
const backendTarget = process.env.
|
| 6 |
|
| 7 |
export default defineConfig({
|
| 8 |
-
// Allow env files to live one level above the frontend directory
|
| 9 |
envDir: path.resolve(__dirname, ".."),
|
| 10 |
plugins: [react()],
|
| 11 |
server: {
|
| 12 |
-
port:
|
| 13 |
host: "0.0.0.0",
|
| 14 |
proxy: {
|
| 15 |
-
"/chatkit": {
|
| 16 |
-
target: backendTarget,
|
| 17 |
-
changeOrigin: true,
|
| 18 |
-
},
|
| 19 |
-
"/auth": {
|
| 20 |
-
target: backendTarget,
|
| 21 |
-
changeOrigin: true,
|
| 22 |
-
},
|
| 23 |
"/api": {
|
| 24 |
target: backendTarget,
|
| 25 |
changeOrigin: true,
|
|
|
|
| 2 |
import { defineConfig } from "vite";
|
| 3 |
import react from "@vitejs/plugin-react-swc";
|
| 4 |
|
| 5 |
+
const backendTarget = process.env.BACKEND_URL ?? "http://127.0.0.1:8000";
|
| 6 |
|
| 7 |
export default defineConfig({
|
|
|
|
| 8 |
envDir: path.resolve(__dirname, ".."),
|
| 9 |
plugins: [react()],
|
| 10 |
server: {
|
| 11 |
+
port: 3003,
|
| 12 |
host: "0.0.0.0",
|
| 13 |
proxy: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"/api": {
|
| 15 |
target: backendTarget,
|
| 16 |
changeOrigin: true,
|