chih.yikuan Claude Opus 4.6 commited on
Commit
bc45e54
·
1 Parent(s): 6884131

Redesign ClassLens v2: 3-step exam analysis workflow

Browse files

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

Files changed (42) hide show
  1. .env +0 -50
  2. .gitignore +13 -0
  3. Dockerfile +1 -4
  4. chatkit/backend/app/auth.py +78 -0
  5. chatkit/backend/app/config.py +28 -35
  6. chatkit/backend/app/database.py +270 -87
  7. chatkit/backend/app/email_service.py +0 -250
  8. chatkit/backend/app/file_processor.py +279 -0
  9. chatkit/backend/app/google_sheets.py +0 -408
  10. chatkit/backend/app/main.py +243 -295
  11. chatkit/backend/app/memory_store.py +0 -115
  12. chatkit/backend/app/oauth.py +0 -251
  13. chatkit/backend/app/report_generator.py +165 -0
  14. chatkit/backend/app/server.py +0 -589
  15. chatkit/backend/app/status_tracker.py +0 -102
  16. chatkit/backend/pyproject.toml +7 -9
  17. chatkit/frontend/index.html +1 -2
  18. chatkit/frontend/package-lock.json +243 -34
  19. chatkit/frontend/package.json +5 -4
  20. chatkit/frontend/src/App.tsx +183 -208
  21. chatkit/frontend/src/TestChat.tsx +0 -12
  22. chatkit/frontend/src/components/AuthStatus.tsx +0 -212
  23. chatkit/frontend/src/components/ExamAnalyzer.tsx +0 -237
  24. chatkit/frontend/src/components/Features.tsx +0 -136
  25. chatkit/frontend/src/components/Header.tsx +25 -22
  26. chatkit/frontend/src/components/Hero.tsx +0 -75
  27. chatkit/frontend/src/components/LoginForm.tsx +142 -0
  28. chatkit/frontend/src/components/ModelSelector.tsx +69 -0
  29. chatkit/frontend/src/components/ReasoningPanel.tsx +0 -178
  30. chatkit/frontend/src/components/SimpleChatPanel.tsx +0 -19
  31. chatkit/frontend/src/components/StepIndicator.tsx +41 -0
  32. chatkit/frontend/src/components/WorkflowStatus.tsx +0 -281
  33. chatkit/frontend/src/components/step1/DataViewer.tsx +112 -0
  34. chatkit/frontend/src/components/step1/FileUploadPanel.tsx +400 -0
  35. chatkit/frontend/src/components/step2/ParsedDataSummary.tsx +48 -0
  36. chatkit/frontend/src/components/step2/PromptEditor.tsx +186 -0
  37. chatkit/frontend/src/components/step3/ReportViewer.tsx +54 -0
  38. chatkit/frontend/src/index.css +0 -36
  39. chatkit/frontend/src/lib/api.ts +88 -0
  40. chatkit/frontend/src/lib/config.ts +0 -15
  41. chatkit/frontend/src/types/html2pdf.d.ts +18 -0
  42. 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
- # ExamInsight - Hugging Face Spaces Docker Deployment
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 from the chatkit directory (parent of backend)
13
- env_path = Path(__file__).parent.parent.parent / ".env"
14
- load_dotenv(env_path)
 
 
15
 
16
 
17
  class Settings(BaseModel):
18
  """Application settings loaded from environment variables."""
19
-
20
- # OpenAI
21
  openai_api_key: str = ""
22
-
23
- # Google OAuth
24
- google_client_id: str = ""
25
- google_client_secret: str = ""
26
- google_redirect_uri: str = "http://localhost:8000/auth/callback"
27
-
28
- # SendGrid
29
- sendgrid_api_key: str = ""
30
- sendgrid_from_email: str = "classlens@example.com"
31
-
32
  # Database
33
- database_url: str = "sqlite+aiosqlite:///./classlens.db"
34
-
35
- # Encryption key for tokens (32 bytes, base64 encoded)
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
- google_client_id=os.getenv("GOOGLE_CLIENT_ID", ""),
51
- google_client_secret=os.getenv("GOOGLE_CLIENT_SECRET", ""),
52
- google_redirect_uri=os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/auth/callback"),
53
- sendgrid_api_key=os.getenv("SENDGRID_API_KEY", ""),
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 token storage."""
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 cryptography.fernet import Fernet
14
-
15
 
16
  DATABASE_PATH = Path(__file__).parent.parent / "classlens.db"
17
 
18
 
19
- def get_encryption_key() -> bytes:
20
- """Get or generate encryption key."""
21
- key = os.getenv("ENCRYPTION_KEY", "")
22
- if not key:
23
- # Generate a new key for development (should be set in production)
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
- def decrypt_token(encrypted_data: str) -> dict:
40
- """Decrypt stored token data."""
41
- key = get_encryption_key()
42
- f = Fernet(key)
43
- encrypted = base64.b64decode(encrypted_data.encode())
44
- decrypted = f.decrypt(encrypted)
45
- return json.loads(decrypted.decode())
 
 
 
 
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 oauth_tokens (
53
- teacher_email TEXT PRIMARY KEY,
54
- encrypted_tokens TEXT NOT NULL,
 
 
 
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 analysis_reports (
62
  id INTEGER PRIMARY KEY AUTOINCREMENT,
63
- teacher_email TEXT NOT NULL,
64
- exam_title TEXT,
65
- report_markdown TEXT,
66
- report_json TEXT,
67
- created_at TEXT NOT NULL
 
68
  )
69
  """)
70
-
71
  await db.commit()
72
 
73
 
74
- async def save_oauth_tokens(teacher_email: str, tokens: dict):
75
- """Save encrypted OAuth tokens for a teacher."""
76
- encrypted = encrypt_token(tokens)
 
77
  now = datetime.utcnow().isoformat()
78
-
79
- async with aiosqlite.connect(DATABASE_PATH) as db:
80
- await db.execute("""
81
- INSERT INTO oauth_tokens (teacher_email, encrypted_tokens, created_at, updated_at)
82
- VALUES (?, ?, ?, ?)
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 get_oauth_tokens(teacher_email: str) -> Optional[dict]:
91
- """Retrieve OAuth tokens for a teacher."""
92
- async with aiosqlite.connect(DATABASE_PATH) as db:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  async with db.execute(
94
- "SELECT encrypted_tokens FROM oauth_tokens WHERE teacher_email = ?",
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 delete_oauth_tokens(teacher_email: str):
104
- """Delete OAuth tokens for a teacher."""
105
- async with aiosqlite.connect(DATABASE_PATH) as db:
 
106
  await db.execute(
107
- "DELETE FROM oauth_tokens WHERE teacher_email = ?",
108
- (teacher_email,)
109
  )
110
  await db.commit()
111
 
112
 
113
- async def save_report(teacher_email: str, exam_title: str, report_markdown: str, report_json: str):
114
- """Save an analysis report."""
 
 
 
 
115
  now = datetime.utcnow().isoformat()
116
-
117
- async with aiosqlite.connect(DATABASE_PATH) as db:
118
- await db.execute("""
119
- INSERT INTO analysis_reports (teacher_email, exam_title, report_markdown, report_json, created_at)
120
- VALUES (?, ?, ?, ?, ?)
121
- """, (teacher_email, exam_title, report_markdown, report_json, now))
122
  await db.commit()
 
123
 
124
 
125
- async def get_teacher_reports(teacher_email: str, limit: int = 10) -> list[dict]:
126
- """Get recent reports for a teacher."""
127
- async with aiosqlite.connect(DATABASE_PATH) as db:
128
  db.row_factory = aiosqlite.Row
129
- async with db.execute("""
130
- SELECT id, exam_title, report_markdown, report_json, created_at
131
- FROM analysis_reports
132
- WHERE teacher_email = ?
133
- ORDER BY created_at DESC
134
- LIMIT ?
135
- """, (teacher_email, limit)) as cursor:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, AsyncIterator
11
 
12
- from chatkit.server import StreamingResult
13
- from fastapi import FastAPI, Request
14
  from fastapi.middleware.cors import CORSMiddleware
15
- from fastapi.responses import JSONResponse, Response, StreamingResponse, FileResponse
16
  from fastapi.staticfiles import StaticFiles
17
  from pydantic import BaseModel
18
 
19
- from .server import ClassLensChatServer
20
- from .oauth import router as oauth_router
21
- from .database import init_database
22
- from .google_sheets import fetch_google_form_responses, parse_csv_responses
23
- from .email_service import send_email_report
24
- from .status_tracker import get_status, subscribe, unsubscribe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="1.0.0",
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
- # ChatKit server instance
71
- chatkit_server = ClassLensChatServer()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  # =============================================================================
75
- # ChatKit Endpoint
76
  # =============================================================================
77
 
78
- @app.get("/api/models")
79
- async def get_available_models():
80
- """Get list of available AI models."""
81
- from .server import AVAILABLE_MODELS, DEFAULT_MODEL
82
- return {
83
- "models": AVAILABLE_MODELS,
84
- "default": DEFAULT_MODEL,
85
- }
86
-
87
-
88
- @app.post("/chatkit")
89
- async def chatkit_endpoint(request: Request) -> Response:
90
- """Proxy the ChatKit web component payload to the server implementation."""
91
- payload = await request.body()
92
-
93
- # Try to extract model from multiple sources
94
- model = (
95
- request.headers.get("X-Model") or
96
- request.query_params.get("model") or
97
- # Check if model is in cookies (set by frontend)
98
- request.cookies.get("selected_model")
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
- # Workflow Status Endpoints
133
  # =============================================================================
134
 
135
- @app.get("/api/status/{session_id}")
136
- async def get_workflow_status(session_id: str):
137
- """Get current workflow status for a session."""
138
- return get_status(session_id)
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Send initial status
146
- status = get_status(session_id)
147
- yield f"data: {json.dumps(status)}\n\n"
148
-
149
- while True:
150
- try:
151
- # Wait for updates with timeout
152
- status = await asyncio.wait_for(queue.get(), timeout=30.0)
153
- yield f"data: {json.dumps(status)}\n\n"
154
- except asyncio.TimeoutError:
155
- # Send keepalive
156
- yield f": keepalive\n\n"
157
- except asyncio.CancelledError:
158
- pass
159
- finally:
160
- unsubscribe(session_id, queue)
161
-
162
-
163
- @app.get("/api/status/{session_id}/stream")
164
- async def stream_workflow_status(session_id: str):
165
- """Stream workflow status updates via Server-Sent Events (SSE)."""
166
- return StreamingResponse(
167
- status_stream(session_id),
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
- # Direct API Endpoints (for non-ChatKit integrations)
179
  # =============================================================================
180
 
181
- class FetchResponsesRequest(BaseModel):
182
- google_form_url: str
183
- teacher_email: str
184
- answer_key: Optional[dict] = None
185
 
186
 
187
- class ParseCSVRequest(BaseModel):
188
- csv_content: str
189
- answer_key: Optional[dict] = None
 
190
 
191
 
192
- class SendEmailRequest(BaseModel):
193
- email: str
194
- subject: str
195
- body_markdown: str
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.post("/api/parse_csv")
214
- async def api_parse_csv(request: ParseCSVRequest):
215
- """
216
- Parse CSV content directly (fallback when Google OAuth is not available).
217
- """
218
- result = parse_csv_responses(request.csv_content, request.answer_key)
219
- return result
220
 
221
 
222
- @app.post("/api/send_email_report")
223
- async def api_send_email(request: SendEmailRequest):
224
- """
225
- Send an exam analysis report via email.
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/health")
236
- @app.get("/health")
237
- async def health_check():
238
- """Health check endpoint for HF Spaces."""
239
- return {"status": "healthy", "service": "ClassLens"}
240
-
241
-
242
- @app.get("/test-routes")
243
- async def test_routes():
244
- """Test endpoint to verify routes are working."""
245
- return {
246
- "message": "Routes are working!",
247
- "report_demo_route": "/report-demo",
248
- "available_paths": [
249
- "/health",
250
- "/api/health",
251
- "/report-demo",
252
- "/test-routes"
253
- ]
254
- }
255
-
256
-
257
- @app.get("/report-template.html")
258
- async def serve_report_template():
259
- """Serve the report template HTML file."""
260
- template_path = Path(__file__).parent.parent / "static" / "report-template.html"
261
- if template_path.exists():
262
- return FileResponse(
263
- template_path,
264
- media_type="text/html",
265
- headers={"Cache-Control": "public, max-age=3600"}
266
- )
267
- return Response(status_code=404, content="Report template not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
 
270
  # =============================================================================
271
- # Report Demo Route
272
  # =============================================================================
273
 
274
- @app.get("/report-demo", response_class=Response)
275
- async def serve_report_demo():
276
- """Serve the HTML report template demo - publicly accessible."""
277
- # Try multiple possible locations (in order of preference)
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
- # Mount static files if directory exists (production Docker build)
338
- if STATIC_DIR.exists():
339
- # Serve static assets (JS, CSS, images)
340
- app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
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
- """Catch-all route to serve React SPA for client-side routing."""
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": "1.0.0",
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 = "examinsight-backend"
3
- version = "0.1.0"
4
- description = "ExamInsight: AI-powered exam analysis for teachers using ChatKit"
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
- "openai-chatkit>=1.4.0,<2",
11
- "google-auth>=2.0.0",
12
- "google-auth-oauthlib>=1.0.0",
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
- "markdown>=3.5.0",
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="Analyze student exam responses, generate personalized explanations, and create peer learning groups with AI." />
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": "examinsight-frontend",
3
- "version": "1.0.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
- "name": "examinsight-frontend",
9
- "version": "1.0.0",
10
  "dependencies": {
11
- "@openai/chatkit-react": ">=1.1.1 <2.0.0",
 
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": "examinsight-frontend",
3
- "version": "1.0.0",
4
- "description": "ExamInsight - AI-Powered Exam Analysis for Teachers",
5
  "private": true,
6
  "type": "module",
7
  "scripts": {
@@ -15,7 +15,8 @@
15
  "npm": ">=9"
16
  },
17
  "dependencies": {
18
- "@openai/chatkit-react": ">=1.1.1 <2.0.0",
 
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 { Hero } from "./components/Hero";
4
- import { AuthStatus } from "./components/AuthStatus";
5
- import { ExamAnalyzer } from "./components/ExamAnalyzer";
6
- import { Features } from "./components/Features";
7
- import { SimpleChatPanel } from "./components/SimpleChatPanel";
8
- import { CLASSLENS_ICON } from "./lib/icon";
9
-
10
- const VALID_INVITE_CODE = "taboola-npo-cz";
11
-
12
- function InviteCodeGate({ onSuccess }: { onSuccess: () => void }) {
13
- const [code, setCode] = useState("");
14
- const [error, setError] = useState(false);
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 [hasAccess, setHasAccess] = useState<boolean | null>(null);
130
- const [teacherEmail, setTeacherEmail] = useState<string>("");
131
- const [isAuthenticated, setIsAuthenticated] = useState(false);
132
- const [showAnalyzer, setShowAnalyzer] = useState(false);
133
- const [testMode, setTestMode] = useState(false);
134
-
135
- // Check for existing access on mount
 
 
 
136
  useEffect(() => {
137
- const access = localStorage.getItem("classlens_access");
138
- setHasAccess(access === "granted");
 
 
 
 
 
 
 
139
  }, []);
140
 
141
- // Check URL params for auth callback and test mode
142
  useEffect(() => {
143
- const params = new URLSearchParams(window.location.search);
144
- const authSuccess = params.get("auth_success");
145
- const email = params.get("email");
146
- const authError = params.get("auth_error");
147
- const test = params.get("test");
148
-
149
- if (test === "chat") {
150
- setTestMode(true);
151
- return;
152
  }
 
153
 
154
- if (authSuccess === "true" && email) {
155
- setTeacherEmail(email);
156
- setIsAuthenticated(true);
157
- setShowAnalyzer(true);
158
- // Clean URL
159
- window.history.replaceState({}, "", window.location.pathname);
160
- } else if (authError) {
161
- console.error("Auth error:", authError);
162
- window.history.replaceState({}, "", window.location.pathname);
163
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  }, []);
165
 
166
- const handleStartAnalysis = () => {
167
- if (isAuthenticated) {
168
- setShowAnalyzer(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  };
171
 
172
  // Loading state
173
- if (hasAccess === null) {
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
- // Invite code gate
182
- if (!hasAccess) {
183
- return <InviteCodeGate onSuccess={() => setHasAccess(true)} />;
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
- isAuthenticated={isAuthenticated}
206
- teacherEmail={teacherEmail}
207
- />
208
-
209
- <main>
210
- {!showAnalyzer ? (
211
- <>
212
- <Hero
213
- isAuthenticated={isAuthenticated}
214
- onStartAnalysis={handleStartAnalysis}
215
- />
216
-
217
- <AuthStatus
218
- teacherEmail={teacherEmail}
219
- setTeacherEmail={setTeacherEmail}
220
- isAuthenticated={isAuthenticated}
221
- setIsAuthenticated={setIsAuthenticated}
222
- />
223
-
224
- <Features />
225
- </>
226
- ) : (
227
- <ExamAnalyzer
228
- teacherEmail={teacherEmail}
229
- onBack={() => setShowAnalyzer(false)}
230
- />
231
- )}
232
- </main>
233
-
234
- <footer className="py-8 text-center text-sm text-[var(--color-text-muted)]">
235
- <p>© 2026 ClassLens • AI-Powered Teaching Assistant</p>
236
- </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>&copy; 2026 ClassLens &bull; 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
- isAuthenticated: boolean;
3
- teacherEmail: string;
4
  }
5
 
6
- export function Header({ isAuthenticated, teacherEmail }: HeaderProps) {
 
 
 
 
 
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
- E
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
- <nav className="flex items-center gap-6">
25
- {isAuthenticated && (
26
- <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
27
- <span className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse"></span>
28
- <span className="text-sm font-medium text-[var(--color-success)]">
29
- {teacherEmail}
30
- </span>
31
- </div>
32
- )}
33
- <a
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
- Docs
40
- </a>
41
- </nav>
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.CHATKIT_API_BASE ?? "http://127.0.0.1:8000";
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: 3000,
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,