Spaces:
Running
ClassLens — Data Access Reference
Last verified: 2026-07-03 against local SQLite dev DB (
classlens.db). All curl examples usehttp://127.0.0.1:8000(local dev server). In production the same paths work against the Supabase-backed server.
1. Database schema
All tables live in the classlens Postgres schema on Supabase (no schema prefix in SQLite dev). Run migrations with alembic upgrade head before starting the server for the first time.
teachers
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
email |
varchar | unique, used as login |
password_hash |
varchar | bcrypt; never exposed to client |
full_name |
varchar | |
display_name |
varchar | shown in UI header |
school |
varchar | |
is_admin |
bool | promoted via ADMIN_EMAILS env var |
students
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
teacher_id |
int FK → teachers | every query is teacher-scoped |
name |
varchar | display name |
normalized_name |
varchar | lower-cased, whitespace-collapsed — used for cross-quiz dedup |
A student is created automatically the first time their name appears in a report. The same student name across multiple quiz uploads maps to the same id via normalized_name.
quizzes
| Column | Type | Notes |
|---|---|---|
id |
int PK | also called session_id in endpoints |
teacher_id |
int FK → teachers | |
title |
varchar | auto-numbered "Quiz N" if omitted |
status |
varchar | draft → processed → saved |
created_at |
timestamptz | used as x-axis in trend chart |
student_results
One row per student per quiz — the central analytics table.
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
quiz_id |
int FK → quizzes | |
student_id |
int FK → students | |
score |
float | 0–100 |
weaknesses |
text | plain-text paragraph from the LLM analytics call |
study_recommendations |
text | plain-text paragraph from the LLM analytics call |
created_at |
timestamptz |
weaknessesandstudy_recommendationsare plain text strings, not JSON. They are written byextract_student_analytics()inreport_generator.py.
parsed_data
One row per question per quiz — populated when a questions PDF is uploaded.
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
quiz_id |
int FK → quizzes | |
question_num |
int | 1-indexed |
question_str |
text | full question text extracted from PDF |
answer |
varchar(10) | correct answer letter (A/B/C/D); empty until answer PDF uploaded |
main_category |
varchar(64) | one of the 6 taxonomy categories; set by background categorization |
tags |
JSON array | sub-tags from that category; set by background categorization |
question_bank
Shared pool accumulated from all teacher uploads (added in migration 0003).
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
quiz_id |
int, nullable | source quiz (SET NULL on quiz delete) |
teacher_id |
int, nullable | uploading teacher (SET NULL on teacher delete) |
question_text |
text | |
answer |
varchar(10) | may be empty |
main_category |
varchar(64), indexed | |
tags |
JSON array |
2. Category taxonomy
Six main categories. Two have sub-tags; four do not.
| Category | Has sub-tags? | Sample tags |
|---|---|---|
| 字詞理解 | Yes | 形容詞用法, 動詞與動詞片語, 情境字彙推斷 |
| 語法結構 | Yes | 現在簡單式, 過去簡單式, 被動語態, 關係代名詞 |
| 文意推論 | No | — |
| 篇章大意 | No | — |
| 篇章細節 | No | — |
| 篇章結構 | No | — |
Full taxonomy is the single source of truth in app/taxonomy.py (backend) and the TAXONOMY constant in TeacherDashboard.tsx (frontend).
3. API endpoints — dashboard components
All endpoints require Authorization: Bearer <token>. Teacher scope is enforced server-side — never pass teacher_id as a query param.
Authentication
POST /api/auth/login
Body: { "email": "...", "password": "..." }
→ { "token": "<jwt>", "user": { "id", "email", "display_name", "full_name", "school", "is_admin" } }
The JWT is stored in localStorage under the key classlens_token.
Student list panel → GET /api/students
Returns all students belonging to the logged-in teacher, with aggregate stats.
{
"students": [
{ "id": 1, "name": "Alice Chen", "avg_score": 90.0, "result_count": 2 },
{ "id": 2, "name": "Bob Lee", "avg_score": 50.0, "result_count": 2 }
]
}
Key fields: name (display), result_count (number of quizzes taken), avg_score (overall average, 0–100 or null if no results).
Student detail + assessment timeline → GET /api/students/{student_id}
Returns the student profile plus a chronological list of all quiz results.
{
"student": { "id": 1, "name": "Alice Chen" },
"timeline": [
{
"quiz_id": 1,
"quiz_title": "Quiz 1",
"quiz_created_at": "2026-07-03T03:50:08",
"score": 100.0,
"weaknesses": "語法結構中的現在簡單式使用正確,無明顯弱點。",
"study_recommendations": "建議延伸學習現在完成式與過去進行式的區別。"
},
{
"quiz_id": 2,
"quiz_title": "Quiz 2",
"quiz_created_at": "2026-07-03T03:50:08",
"score": 80.0,
"weaknesses": "過去簡單式偶有混淆,被動語態理解待加強。",
"study_recommendations": "建議針對過去簡單式與被動語態進行專項練習。"
}
],
"weaknesses": "過去簡單式偶有混淆,被動語態理解待加強。",
"study_recommendations": "建議針對過去簡單式與被動語態進行專項練習。"
}
How timeline maps to dashboard components:
| Dashboard element | Source field |
|---|---|
| Assessment trend chart (y-axis) | timeline[].score |
| Chart x-axis labels | "Quiz " + (index+1) — position-based, not quiz_title |
| Chart tooltip date | timeline[].quiz_created_at |
| Focus Areas text (most recent) | top-level weaknesses |
| Study Plan text (most recent) | top-level study_recommendations |
The top-level weaknesses / study_recommendations are the most recent non-null values from the timeline — not a synthesis.
AI synthesis (Summary / Focus Areas / Study Recommendations) → GET /api/students/{student_id}/ai-summary
Calls the LLM to synthesize insights across all quizzes into three distinct paragraphs.
{
"summary": "Alice has maintained strong performance across both quizzes...",
"focus_areas": "Alice's recurring challenge is passive voice construction...",
"study_recommendations": "Review 被動語態 by practising sentence transformations..."
}
The dashboard fetches this in parallel with the student detail call. The three fields map directly to the Summary, Focus Areas, and Study Recommendations cards.
Questions with categories → GET /api/sessions/{session_id}/parsed-data
Returns every question for a quiz with its category and tags. Used by the question generation panel.
{
"parsed_data": {
"questions": [
{
"id": 1,
"quiz_id": 1,
"question_num": 1,
"question_str": "She _____ to school every day.",
"answer": "A",
"main_category": "語法結構",
"tags": ["時態", "現在簡單式"],
"created_at": "2026-07-03T03:50:08"
}
]
}
}
main_category and tags are populated by the background categorization job that runs after each questions PDF upload. They may be empty strings/arrays until that job completes.
Quiz list → GET /api/sessions
{
"sessions": [
{ "id": 1, "teacher_id": 1, "title": "Quiz 1", "status": "draft", "created_at": "..." },
{ "id": 2, "teacher_id": 1, "title": "Quiz 2", "status": "draft", "created_at": "..." }
]
}
4. Test results — confirmed working (2026-07-03)
All tests run against local SQLite DB, server at http://127.0.0.1:8000.
# Start server
cd chatkit/backend
DATABASE_PROVIDER=sqlite uvicorn app.main:app --port 8000
# Get token
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@school.edu","password":"testpass123"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 1. Health check ✓ → 200
curl http://127.0.0.1:8000/api/health
# { "status": "healthy", "service": "ClassLens", "version": "2.0.0" }
# 2. Student list ✓ → 200
curl http://127.0.0.1:8000/api/students \
-H "Authorization: Bearer $TOKEN"
# { "students": [{ "id":1, "name":"Alice Chen", "avg_score":90.0, "result_count":2 }, ...] }
# 3. Student detail + timeline ✓ → 200
curl http://127.0.0.1:8000/api/students/1 \
-H "Authorization: Bearer $TOKEN"
# { "student": {...}, "timeline": [{ "score":100.0, ... }, { "score":80.0, ... }], ... }
# 4. Quiz list ✓ → 200
curl http://127.0.0.1:8000/api/sessions \
-H "Authorization: Bearer $TOKEN"
# { "sessions": [{ "id":1, "title":"Quiz 1" }, { "id":2, "title":"Quiz 2" }] }
# 5. Questions with categories ✓ → 200
curl http://127.0.0.1:8000/api/sessions/1/parsed-data \
-H "Authorization: Bearer $TOKEN"
# { "parsed_data": { "questions": [{ "question_num":1, "main_category":"語法結構", "tags":["時態","現在簡單式"] }, ...] } }
# 6. AI summary (requires OPENAI_API_KEY set)
curl http://127.0.0.1:8000/api/students/1/ai-summary \
-H "Authorization: Bearer $TOKEN"
# { "summary": "...", "focus_areas": "...", "study_recommendations": "..." }
5. Notes for the data layer
Teacher scope is automatic. The server joins through
teacher_id— never pass it as a query param.Student identity is name-matched. The
normalized_namecolumn (lower-case, single-space) deduplicates the same student across multiple quiz uploads.match_or_create_student()is called during report generation.weaknessesis plain text. It is a paragraph written by the analytics LLM, not a structured JSON array. The dashboard renders it as a text block under the Focus Areas heading.Category data lags the upload by seconds.
main_categoryandtagsinparsed_dataare filled by a backgroundasyncio.create_taskthat runs after the upload endpoint returns. They will be empty immediately after upload and populated within a few seconds.The 4 reading categories have no sub-tags.
文意推論,篇章大意,篇章細節,篇章結構—tagswill always be[]for questions in these categories.Local dev — no Supabase needed:
cd chatkit/backend DATABASE_PROVIDER=sqlite uvicorn app.main:app --port 8000The SQLite file is
chatkit/backend/classlens.db. If it's missing or stale,init_database()creates it fresh on startup usingBase.metadata.create_all.Seed test data after recreating the DB:
# See chatkit/backend/tests/test_database.py for fixture pattern DATABASE_PROVIDER=sqlite python3 -c " import asyncio from app.database import init_database, create_user, ... asyncio.run(seed()) "