prepodov-ai / server.py
damadan's picture
Deploy PrepodovAI v0.18.2 runtime
dd528d9 verified
Raw
History Blame Contribute Delete
140 kB
#!/usr/bin/env python3
"""PrepodovAI runtime server — единая точка входа teacher/student UI.
КАК ЗАПУСТИТЬ
-------------
python server.py # с дефолтами из runtime_config.py
cp .env.example .env && edit .env # предварительно задать параметры
docker compose up -d runtime # в контейнере с persistent volume
Адреса:
http://127.0.0.1:8000/teacher — UI преподавателя
http://127.0.0.1:8000/student — UI студента
http://127.0.0.1:8000/login — регистрация / вход
http://127.0.0.1:8000/health/live — kubernetes liveness probe
http://127.0.0.1:8000/health/ready — readiness probe (DB + disk)
http://127.0.0.1:8000/health/llm — статус активного LLM-провайдера
ХРАНИЛИЩЕ
---------
data/app.db — SQLite (WAL-режим)
data/uploads/ — загруженные файлы (uuid-префикс)
data/sample-course-*.json — fallback-курс, если LLM недоступен
На облаке задать абсолютный путь через PREPODOV_DATA_DIR=/data и смонтировать
persistent volume на эту папку.
LLM-БЭКЕНДЫ (одна переменная PREPODOV_LLM_BACKEND)
--------------------------------------------------
gemini — Google Gemini REST (требует PREPODOV_GEMINI_API_KEY)
openai — любой OpenAI-API (Ollama / vLLM / OpenAI / Groq / ...)
local — offline mlx-lm Qwen (macOS Apple Silicon, pip install ".[local-llm]")
hosted — устаревший псевдоним для gemini (сохранён для совместимости)
Каждый вызов модели обёрнут в ResilientLLMClient: ретраи с экспоненциальным
backoff + circuit breaker + жёсткий per-call timeout. Падение провайдера
никогда не валит бизнес-поток — UI получает sample/fallback-draft.
АРХИТЕКТУРА
-----------
1. stdlib BaseHTTPRequestHandler + ThreadingHTTPServer (без FastAPI/uvicorn).
2. SQLite с tuned PRAGMAs (WAL, busy_timeout, mmap) — подходит до ~500 юзеров.
3. Аутентификация через prepodov_ai.webauth:
- scrypt-хэш пароля (stdlib hashlib);
- HttpOnly / SameSite=Lax session-cookie (30 дней TTL по умолчанию).
4. Мультикурсовая изоляция: X-Course-Id header scope-ит каждый запрос
преподавателя; студент видит только свои submission-ы.
5. Security headers (CSP, X-Frame-Options, X-Content-Type-Options) на всё.
6. Graceful shutdown: SIGINT / SIGTERM / SIGBREAK → корректное завершение.
Подробнее в README.md и docs/runtime-flow.md.
"""
import contextvars
import json
import logging
import os
import re
import shutil
import sqlite3
import sys
import tempfile
import threading
import traceback
import xml.etree.ElementTree as ET
import zipfile
from dataclasses import dataclass
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from prepodov_ai.infrastructure.policies import IngestionPolicy
from prepodov_ai.ingestion.extraction_router import (
FileExtractionRouter as RuntimeFileExtractionRouter,
)
from prepodov_ai.ingestion.extraction_types import StructuredExtractionResult
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
EXTRACTION_ROUTER = RuntimeFileExtractionRouter(policy=IngestionPolicy())
# ---------------------------------------------------------------------------
# Конфигурация — единый источник правды.
# Все параметры читаются из .env (или переменных окружения).
# Для изменения настроек редактируй .env (скопируй из .env.example).
# ---------------------------------------------------------------------------
from prepodov_ai.runtime_config import cfg # noqa: E402
# Resolve data paths. Order of precedence:
# 1. PREPODOV_DATA_DIR env var (absolute path — for cloud persistent volumes)
# 2. BASE_DIR / cfg.data_dir_name (legacy relative path, default "data")
# Upload dir is derived but overridable via PREPODOV_UPLOAD_DIR.
DATA_DIR = (
Path(cfg.data_dir).resolve() if cfg.data_dir else (BASE_DIR / cfg.data_dir_name).resolve()
)
UPLOAD_DIR = Path(cfg.upload_dir).resolve() if cfg.upload_dir else (DATA_DIR / "uploads")
DB_PATH = DATA_DIR / "app.db"
SAMPLE_COURSE_PATH = DATA_DIR / "sample-course-bioinformatics.json"
# LLM — unified client; module-level for monkeypatch compatibility
GEMINI_API_KEY: str | None = cfg.gemini_api_key
GEMINI_MODEL: str = cfg.gemini_model
GEMINI_API_BASE: str = cfg.gemini_api_base
# "hosted" is the legacy alias for "gemini"
_LLM_BACKEND_RAW: str = cfg.llm_backend.strip().lower()
LLM_BACKEND: str = "gemini" if _LLM_BACKEND_RAW == "hosted" else _LLM_BACKEND_RAW
MAX_LLM_INPUT_CHARS: int = cfg.max_llm_input_chars
from prepodov_ai.llm import LLMConfig, LLMError, LLMRequest, build_llm_client # noqa: E402
from prepodov_ai.llm.feedback_pipeline import ( # noqa: E402
ChunkingOptions,
generate_chunked_feedback,
)
from prepodov_ai.llm.prompts import build_course_prompt, build_feedback_prompt # noqa: E402
from prepodov_ai.llm.resilient import ResiliencePolicy # noqa: E402
from prepodov_ai.webauth import ( # noqa: E402
UserAlreadyExistsError,
create_session,
create_user,
delete_session,
find_user_by_email,
parse_session_cookie,
resolve_session,
verify_password,
)
from prepodov_ai.webauth.passwords import PasswordError # noqa: E402
from prepodov_ai.webauth.repository import ValidationError # noqa: E402
from prepodov_ai.webauth.sessions import ( # noqa: E402
format_expire_cookie,
format_set_cookie,
)
_RESILIENCE_POLICY = ResiliencePolicy(
max_attempts=cfg.llm_retry_max_attempts,
base_delay_ms=cfg.llm_retry_base_delay_ms,
max_delay_ms=cfg.llm_retry_max_delay_ms,
per_call_timeout_s=cfg.llm_per_call_timeout_seconds,
breaker_failure_threshold=cfg.llm_breaker_failure_threshold,
breaker_reset_seconds=cfg.llm_breaker_reset_seconds,
)
try:
LLM_CLIENT = build_llm_client(
LLMConfig(
backend=LLM_BACKEND, # type: ignore[arg-type]
gemini_api_key=cfg.gemini_api_key or "",
gemini_model=cfg.gemini_model,
gemini_api_base=cfg.gemini_api_base,
openai_base_url=cfg.openai_base_url,
openai_api_key=cfg.openai_api_key,
openai_model=cfg.openai_model,
local_model_id=cfg.local_model_id,
timeout_seconds=cfg.llm_timeout_seconds,
max_input_chars=cfg.max_llm_input_chars,
temperature=cfg.llm_temperature,
max_output_tokens=cfg.llm_max_output_tokens,
),
policy=_RESILIENCE_POLICY,
)
except Exception as _llm_init_exc:
sys.stderr.write(
f"[server] LLM init failed ({_llm_init_exc}); requests will use sample fallback.\n"
)
LLM_CLIENT = None # type: ignore[assignment]
TESSERACT_CMD: str | None = shutil.which("tesseract")
_OCR_PORT = None
if TESSERACT_CMD is not None:
from prepodov_ai.infrastructure.ocr import TesseractOCRPort
from prepodov_ai.infrastructure.settings import Settings as _InfraSettings
_OCR_PORT = TesseractOCRPort(_InfraSettings())
_MIME_TO_SUFFIX: dict[str, str] = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/tiff": ".tif",
"image/webp": ".webp",
}
# The canonical regex/filter definitions live in `prepodov_ai.llm.prompts`.
# These module-level aliases exist so legacy test modules that import
# `server._MD_NOISE` / `server._INJECTION_MARKERS` / `server._sanitize_for_prompt`
# keep working after the helpers were centralized. Assigned from the prompts
# module (not re-imported) so `ruff --fix` does not strip them as "unused".
from prepodov_ai.llm import prompts as _prompts # noqa: E402
_MD_NOISE = _prompts._MD_NOISE
_INJECTION_MARKERS = _prompts._INJECTION_MARKERS
_sanitize_for_prompt = _prompts.sanitize_for_prompt
strip_markdown = _prompts.strip_markdown
DEFAULT_RUBRIC = {
"criteria": [
{
"title": "Точность",
"weight": 0.4,
"levels": {
"5": "Ошибок нет, все факты корректны",
"4": "Незначительные неточности",
"3": "Заметные ошибки",
"2": "Критические ошибки",
},
},
{
"title": "Полнота",
"weight": 0.3,
"levels": {
"5": "Все ключевые пункты раскрыты",
"4": "Есть мелкие упущения",
"3": "Часть важных пунктов пропущена",
"2": "Существенные пробелы",
},
},
{
"title": "Логика и структура",
"weight": 0.3,
"levels": {
"5": "Отличная структура и аргументация",
"4": "В целом логично",
"3": "Структура слабая",
"2": "Ответ фрагментарный",
},
},
]
}
VALID_GRADES = frozenset({"2", "3", "4", "5"})
@dataclass(frozen=True, slots=True)
class RuntimeExtraction:
text: str
warnings: list[str]
fallback_trace: list[str]
capabilities_used: list[str]
requires_ocr: bool = False
ocr_applied: bool = False
_PROCESS_START = __import__("time").monotonic()
def _uptime_seconds() -> int:
import time as _time
return int(_time.monotonic() - _PROCESS_START)
def now_iso():
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
# SQLite production tuning. Values chosen for a ~100-student teacher tool on a
# single server with persistent volume. WAL enables concurrent readers while a
# writer holds the write lock; busy_timeout lets sqlite auto-retry locked
# statements for up to 5 s before raising; synchronous=NORMAL is safe with WAL
# (trades an infinitesimal crash-window for ~5× write throughput); mmap and
# cache tuning pay for themselves as soon as the DB gets more than ~100 rows.
_SQLITE_STARTUP_PRAGMAS = (
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
"PRAGMA cache_size=-20000", # kibibytes; negative ⇒ 20 MB
"PRAGMA mmap_size=268435456", # 256 MB memory-mapped I/O window
"PRAGMA temp_store=MEMORY",
"PRAGMA wal_autocheckpoint=1000",
"PRAGMA foreign_keys=ON",
)
# Per-connection PRAGMAs — the ones SQLite requires on every open.
_SQLITE_CONNECTION_PRAGMAS = ("PRAGMA foreign_keys=ON", "PRAGMA busy_timeout=5000")
def connect_db() -> sqlite3.Connection:
"""Open a tuned SQLite connection suitable for the request-handler threads.
Each connection lives for the duration of one request — SQLite connections
are cheap (sub-millisecond on WAL mode) and thread-local ownership avoids
the "cannot reuse across threads" trap from the stdlib driver.
"""
conn = sqlite3.connect(
DB_PATH,
timeout=5.0, # seconds the driver will wait for locks before raising
)
for pragma in _SQLITE_CONNECTION_PRAGMAS:
conn.execute(pragma)
return conn
def with_db_retry(fn, *, max_attempts: int = 3, base_delay_ms: int = 50):
"""Run ``fn(conn)`` under a transient-lock retry loop.
SQLite raises ``OperationalError: database is locked`` under write
contention; retrying with a small back-off is the canonical fix for
a write-heavy moment (bulk draft generation, concurrent enrolments).
The helper opens + closes the connection for the caller so leaks are
impossible on the retry path.
"""
import time as _time
last_exc: BaseException | None = None
for attempt in range(1, max_attempts + 1):
conn = connect_db()
try:
return fn(conn)
except sqlite3.OperationalError as exc:
last_exc = exc
message = str(exc).lower()
if "locked" not in message and "busy" not in message:
raise
if attempt == max_attempts:
raise
_time.sleep((base_delay_ms / 1000.0) * (2 ** (attempt - 1)))
finally:
try:
conn.close()
except Exception:
pass
if last_exc is not None:
raise last_exc
raise RuntimeError("with_db_retry exhausted attempts without an error")
def _apply_startup_pragmas(conn: sqlite3.Connection) -> None:
"""Apply one-time server-startup tuning PRAGMAs.
Idempotent: re-running is cheap, so we call this on every ``init_db()``.
"""
for pragma in _SQLITE_STARTUP_PRAGMAS:
try:
conn.execute(pragma)
except sqlite3.OperationalError as exc:
sys.stderr.write(f"[db] pragma failed: {pragma!r}{exc}\n")
def init_db():
conn = connect_db()
_apply_startup_pragmas(conn)
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS course (
id INTEGER PRIMARY KEY,
json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assignment (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
lesson_title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
rubric_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS submission (
id INTEGER PRIMARY KEY,
assignment_id INTEGER NOT NULL,
student_name TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY (assignment_id) REFERENCES assignment(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS feedback_draft (
id INTEGER PRIMARY KEY,
submission_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (submission_id) REFERENCES submission(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS feedback_final (
id INTEGER PRIMARY KEY,
submission_id INTEGER NOT NULL,
content TEXT NOT NULL,
grade TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (submission_id) REFERENCES submission(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('teacher','student','admin')),
display_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_email ON user(email);
CREATE TABLE IF NOT EXISTS session (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_user ON session(user_id);
CREATE TABLE IF NOT EXISTS enrollment (
id INTEGER PRIMARY KEY,
course_id INTEGER NOT NULL,
student_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(course_id, student_id),
FOREIGN KEY (course_id) REFERENCES course(id) ON DELETE CASCADE,
FOREIGN KEY (student_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_enrollment_student ON enrollment(student_id);
"""
)
# --- Schema evolutions -------------------------------------------------
cols = [row[1] for row in conn.execute("PRAGMA table_info(assignment)").fetchall()]
if "description" not in cols:
conn.execute("ALTER TABLE assignment ADD COLUMN description TEXT NOT NULL DEFAULT ''")
if "course_id" not in cols:
conn.execute("ALTER TABLE assignment ADD COLUMN course_id INTEGER NOT NULL DEFAULT 1")
sub_cols = [row[1] for row in conn.execute("PRAGMA table_info(submission)").fetchall()]
if "domain" not in sub_cols:
conn.execute("ALTER TABLE submission ADD COLUMN domain TEXT NOT NULL DEFAULT 'general'")
if "attachment_name" not in sub_cols:
conn.execute("ALTER TABLE submission ADD COLUMN attachment_name TEXT")
if "extraction_warnings" not in sub_cols:
conn.execute("ALTER TABLE submission ADD COLUMN extraction_warnings TEXT")
if "student_id" not in sub_cols:
conn.execute("ALTER TABLE submission ADD COLUMN student_id INTEGER")
course_cols = [row[1] for row in conn.execute("PRAGMA table_info(course)").fetchall()]
if "teacher_id" not in course_cols:
conn.execute("ALTER TABLE course ADD COLUMN teacher_id INTEGER")
if "title" not in course_cols:
conn.execute("ALTER TABLE course ADD COLUMN title TEXT NOT NULL DEFAULT 'Курс'")
if "join_code" not in course_cols:
conn.execute("ALTER TABLE course ADD COLUMN join_code TEXT")
if "subject_area" not in course_cols:
conn.execute("ALTER TABLE course ADD COLUMN subject_area TEXT NOT NULL DEFAULT 'general'")
if "grade_level" not in course_cols:
conn.execute("ALTER TABLE course ADD COLUMN grade_level TEXT")
conn.commit()
conn.close()
def empty_course():
return {
"course": {
"title": "",
"semester": None,
"credits": None,
"totalHours": None,
"finalControl": "",
"competencies": [],
"modules": [],
}
}
def load_sample_course():
if SAMPLE_COURSE_PATH.exists():
return json.loads(SAMPLE_COURSE_PATH.read_text(encoding="utf-8"))
return empty_course()
LEGACY_COURSE_ID = 1
def get_course(conn, course_id: int = LEGACY_COURSE_ID):
row = conn.execute("SELECT json FROM course WHERE id=?", (course_id,)).fetchone()
if row:
return json.loads(row[0])
return empty_course()
def save_course(
conn,
course_json,
rubric_by_lesson=None,
description_by_lesson=None,
*,
course_id: int = LEGACY_COURSE_ID,
):
# Ensure the course row exists. For the legacy singleton we INSERT OR UPDATE on id=1;
# for multi-course we update an existing row (created via POST /api/my/courses).
existing = conn.execute("SELECT 1 FROM course WHERE id=?", (course_id,)).fetchone()
payload = json.dumps(course_json, ensure_ascii=False)
if existing:
conn.execute(
"UPDATE course SET json=?, updated_at=? WHERE id=?",
(payload, now_iso(), course_id),
)
else:
conn.execute(
"INSERT INTO course(id, json, updated_at, title) VALUES(?,?,?,'Курс')",
(course_id, payload, now_iso()),
)
sync_assignments(
conn,
course_json,
rubric_by_lesson=rubric_by_lesson or {},
description_by_lesson=description_by_lesson or {},
course_id=course_id,
)
conn.commit()
def extract_lessons(course_json):
lessons = []
modules = course_json.get("course", {}).get("modules", [])
for m in modules:
for t in m.get("topics", []):
for lesson in t.get("lessons", []):
lessons.append(lesson)
return lessons
def sync_assignments(
conn,
course_json,
rubric_by_lesson=None,
description_by_lesson=None,
*,
course_id: int = LEGACY_COURSE_ID,
):
conn.execute("DELETE FROM assignment WHERE course_id=?", (course_id,))
lessons = extract_lessons(course_json)
rubric_by_lesson = rubric_by_lesson or {}
description_by_lesson = description_by_lesson or {}
for lesson in lessons:
if lesson.get("type") != "practice":
continue
lesson_title = lesson.get("title", "Задание")
title = (
lesson_title
if str(lesson_title).strip().lower().startswith("практика")
else f"Практика: {lesson_title}"
)
rubric = rubric_by_lesson.get(lesson_title, DEFAULT_RUBRIC)
description = description_by_lesson.get(lesson_title, "")
conn.execute(
"INSERT INTO assignment(title, lesson_title, description, rubric_json, course_id) "
"VALUES(?, ?, ?, ?, ?)",
(
title,
lesson_title,
description,
json.dumps(rubric, ensure_ascii=False),
course_id,
),
)
def list_assignments(conn, course_id: int = LEGACY_COURSE_ID):
rows = conn.execute(
"SELECT id, title, lesson_title, description, rubric_json "
"FROM assignment WHERE course_id=?",
(course_id,),
).fetchall()
return [
{
"id": r[0],
"title": r[1],
"lesson_title": r[2],
"description": r[3],
"rubric": json.loads(r[4]),
}
for r in rows
]
def list_submissions(conn, course_id: int = LEGACY_COURSE_ID):
rows = conn.execute(
"SELECT s.id, s.assignment_id, s.student_name, s.content, s.created_at, s.status, a.title, "
"s.domain, s.attachment_name "
"FROM submission s JOIN assignment a ON s.assignment_id = a.id "
"WHERE a.course_id = ? ORDER BY s.created_at DESC",
(course_id,),
).fetchall()
return [
{
"id": r[0],
"assignment_id": r[1],
"student_name": r[2],
"content": r[3],
"created_at": r[4],
"status": r[5],
"assignment_title": r[6],
"domain": r[7] or "general",
"attachment_name": r[8],
}
for r in rows
]
def get_submission(conn, submission_id):
row = conn.execute(
"SELECT s.id, s.assignment_id, s.student_name, s.content, s.created_at, s.status, "
"a.title, a.rubric_json, a.description, s.domain, s.attachment_name, s.extraction_warnings "
"FROM submission s JOIN assignment a ON s.assignment_id = a.id WHERE s.id=?",
(submission_id,),
).fetchone()
if not row:
return None
warnings = row[11]
try:
warnings_list = json.loads(warnings) if warnings else []
except json.JSONDecodeError:
warnings_list = []
return {
"id": row[0],
"assignment_id": row[1],
"student_name": row[2],
"content": row[3],
"created_at": row[4],
"status": row[5],
"assignment_title": row[6],
"rubric": json.loads(row[7]),
"assignment_description": row[8],
"domain": row[9] or "general",
"attachment_name": row[10],
"extraction_warnings": warnings_list,
}
def assignment_exists(conn: sqlite3.Connection, assignment_id: object) -> bool:
row = conn.execute("SELECT 1 FROM assignment WHERE id=?", (assignment_id,)).fetchone()
return row is not None
def normalize_student_name(value: object) -> str:
name = str(value or "").strip()
return name or "Student"
def normalize_domain(value: object) -> str:
"""Canonicalize a user-supplied domain key via the shared registry."""
from prepodov_ai.llm.domains import normalize_domain_key
return normalize_domain_key(str(value or ""))
def validate_final_grade(value: object) -> str | None:
if value is None:
return None
grade = str(value).strip()
if not grade:
return None
if grade not in VALID_GRADES:
raise ValueError("grade must be one of: 2, 3, 4, 5")
return grade
def set_submission_status(conn, submission_id, status):
conn.execute("UPDATE submission SET status=? WHERE id=?", (status, submission_id))
def store_feedback_draft(conn, submission_id, content):
conn.execute(
"INSERT INTO feedback_draft(submission_id, content, created_at) VALUES(?, ?, ?)",
(submission_id, content, now_iso()),
)
set_submission_status(conn, submission_id, "draft_ready")
def store_feedback_final(conn, submission_id, content, grade):
conn.execute(
"INSERT INTO feedback_final(submission_id, content, grade, created_at) VALUES(?, ?, ?, ?)",
(submission_id, content, grade, now_iso()),
)
set_submission_status(conn, submission_id, "finalized")
def list_feedback_draft(conn, submission_id):
row = conn.execute(
"SELECT content FROM feedback_draft WHERE submission_id=? ORDER BY id DESC LIMIT 1",
(submission_id,),
).fetchone()
return row[0] if row else None
def list_feedback_final(conn, submission_id):
row = conn.execute(
"SELECT content, grade FROM feedback_final WHERE submission_id=? ORDER BY id DESC LIMIT 1",
(submission_id,),
).fetchone()
if not row:
return None
return {"content": row[0], "grade": row[1]}
def list_students(conn, course_id: int = LEGACY_COURSE_ID):
# We aggregate on student_id when available (authenticated enrollments)
# and fall back to student_name for legacy submissions from unauthenticated
# posters. The returned ``student_id`` is the FK users.id — it's the
# stable handle the UI needs to wire the "remove from course" button.
rows = conn.execute(
"""
SELECT COALESCE(s.student_id, -1) AS uid,
s.student_name,
COUNT(s.id) AS total,
SUM(CASE WHEN s.status='submitted' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN s.status='draft_ready' THEN 1 ELSE 0 END) AS drafts,
SUM(CASE WHEN s.status='finalized' THEN 1 ELSE 0 END) AS finalized,
AVG(CAST(ff.grade AS REAL)) AS avg_grade,
MAX(s.created_at) AS last_activity
FROM submission s
JOIN assignment a ON a.id = s.assignment_id
LEFT JOIN (
SELECT f1.submission_id, f1.grade
FROM feedback_final f1
JOIN (
SELECT submission_id, MAX(id) AS max_id
FROM feedback_final GROUP BY submission_id
) f2 ON f1.id = f2.max_id
) ff ON ff.submission_id = s.id
WHERE a.course_id = ?
GROUP BY uid, s.student_name
ORDER BY last_activity DESC
""",
(course_id,),
).fetchall()
return [
{
# -1 sentinel is swapped to None so the UI does an honest
# "unenroll" affordance only for authenticated students.
"student_id": int(r[0]) if int(r[0]) > 0 else None,
"student_name": r[1],
"total": r[2] or 0,
"pending": r[3] or 0,
"drafts": r[4] or 0,
"finalized": r[5] or 0,
"avg_grade": round(r[6], 2) if r[6] is not None else None,
"last_activity": r[7],
}
for r in rows
]
def get_submission_detail(conn, submission_id):
base = get_submission(conn, submission_id)
if not base:
return None
draft = list_feedback_draft(conn, submission_id)
final = list_feedback_final(conn, submission_id)
base["draft"] = draft
base["final"] = final
return base
def delete_submission(conn, submission_id) -> bool:
row = conn.execute("SELECT 1 FROM submission WHERE id=?", (submission_id,)).fetchone()
if not row:
return False
conn.execute("DELETE FROM submission WHERE id=?", (submission_id,))
conn.commit()
return True
def update_assignment(conn, assignment_id, *, description=None, rubric=None, title=None) -> bool:
row = conn.execute("SELECT 1 FROM assignment WHERE id=?", (assignment_id,)).fetchone()
if not row:
return False
sets = []
params: list = []
if description is not None:
sets.append("description=?")
params.append(str(description))
if rubric is not None:
sets.append("rubric_json=?")
params.append(json.dumps(rubric, ensure_ascii=False))
if title is not None and str(title).strip():
sets.append("title=?")
params.append(str(title).strip())
if not sets:
return True
params.append(assignment_id)
conn.execute(f"UPDATE assignment SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return True
def dashboard_summary(conn, course_id: int = LEGACY_COURSE_ID):
def _scalar(sql, *params):
row = conn.execute(sql, params).fetchone()
return row[0] if row and row[0] is not None else 0
total = _scalar(
"SELECT COUNT(*) FROM submission s JOIN assignment a ON s.assignment_id=a.id "
"WHERE a.course_id=?",
course_id,
)
pending = _scalar(
"SELECT COUNT(*) FROM submission s JOIN assignment a ON s.assignment_id=a.id "
"WHERE a.course_id=? AND s.status='submitted'",
course_id,
)
# Drafts and finals are counted from their respective history tables — this
# preserves the "at least one draft/final ever existed" semantic even after
# a submission has been finalized (status transitioned to 'finalized').
drafts = _scalar(
"SELECT COUNT(DISTINCT fd.submission_id) FROM feedback_draft fd "
"JOIN submission s ON s.id = fd.submission_id "
"JOIN assignment a ON a.id = s.assignment_id "
"WHERE a.course_id=?",
course_id,
)
finals = _scalar(
"SELECT COUNT(DISTINCT ff.submission_id) FROM feedback_final ff "
"JOIN submission s ON s.id = ff.submission_id "
"JOIN assignment a ON a.id = s.assignment_id "
"WHERE a.course_id=?",
course_id,
)
row = conn.execute(
"SELECT AVG(CAST(f.grade AS REAL)) FROM feedback_final f "
"JOIN submission s ON s.id = f.submission_id "
"JOIN assignment a ON a.id = s.assignment_id "
"WHERE a.course_id=? AND f.grade IS NOT NULL",
(course_id,),
).fetchone()
raw_avg = row[0] if row else None
if raw_avg is None:
avg_grade = None
else:
avg_grade = round(raw_avg, 2)
# Keep as int when the result is an exact whole number — test invariants
# and UI both prefer "4" over "4.0".
if float(avg_grade).is_integer():
avg_grade = int(avg_grade)
recent = conn.execute(
"SELECT s.id, s.assignment_id, s.student_name, s.created_at, s.status, a.title "
"FROM submission s JOIN assignment a ON s.assignment_id=a.id "
"WHERE a.course_id=? ORDER BY s.created_at DESC LIMIT 5",
(course_id,),
).fetchall()
assignments = _scalar("SELECT COUNT(*) FROM assignment WHERE course_id=?", course_id)
students = _scalar(
"SELECT COUNT(DISTINCT COALESCE(s.student_id, s.student_name)) FROM submission s "
"JOIN assignment a ON a.id = s.assignment_id WHERE a.course_id=?",
course_id,
)
return {
"total_submissions": total,
"pending": pending,
"drafts": drafts,
"finals": finals,
"finalized": finals,
"avg_grade": round(avg_grade, 2) if avg_grade is not None else None,
"assignments": assignments,
"students": students,
"llm_backend": LLM_BACKEND,
"recent": [
{
"id": r[0],
"assignment_id": r[1],
"student_name": r[2],
"created_at": r[3],
"status": r[4],
"assignment_title": r[5],
}
for r in recent
],
}
def list_student_feedback(
conn,
*,
student_name: str | None = None,
student_id: int | None = None,
course_id: int | None = None,
):
"""Return a student's own submissions+feedback.
Authenticated callers pass ``student_id`` for a privacy-safe lookup by
user ID; the ``student_name`` path is kept for legacy unauthenticated
clients (scoped to LEGACY_COURSE_ID only by the HTTP layer).
"""
clauses: list[str] = []
params: list = []
if student_id is not None:
clauses.append("s.student_id = ?")
params.append(int(student_id))
elif student_name is not None:
clauses.append("s.student_name = ?")
params.append(str(student_name))
else:
return []
if course_id is not None:
clauses.append("a.course_id = ?")
params.append(int(course_id))
where_clause = " AND ".join(clauses)
rows = conn.execute(
f"""
SELECT s.id, s.assignment_id, s.content, s.created_at, s.status, a.title,
ff.content, ff.grade
FROM submission s
JOIN assignment a ON s.assignment_id = a.id
LEFT JOIN (
SELECT f1.submission_id, f1.content, f1.grade
FROM feedback_final f1
JOIN (
SELECT submission_id, MAX(id) AS max_id
FROM feedback_final
GROUP BY submission_id
) f2 ON f1.id = f2.max_id
) ff ON ff.submission_id = s.id
WHERE {where_clause}
ORDER BY s.created_at DESC
""",
tuple(params),
).fetchall()
return [
{
"submission_id": r[0],
"assignment_id": r[1],
"submission_content": r[2],
"created_at": r[3],
"status": r[4],
"assignment_title": r[5],
"feedback": r[6],
"grade": r[7],
}
for r in rows
]
_SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9._\-]+$")
_WINDOWS_RESERVED_NAMES = frozenset(
{
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}
)
def _safe_upload_filename(raw: str) -> str | None:
"""Sanitize a user-supplied filename.
Returns a server-safe variant or ``None`` if the name is impossible to
sanitize (empty, path-traversing, reserved). Keeps the original extension
when it's alphanumeric; otherwise drops it.
"""
if not raw:
return None
# Strip path components from both POSIX and Windows-style names.
candidate = os.path.basename(raw.replace("\\", "/")).strip()
if not candidate or candidate in {".", ".."}:
return None
if candidate.startswith(".") or "\x00" in candidate:
return None
# Chop too-long names while keeping the extension.
stem, dot, ext = candidate.rpartition(".")
if stem and dot and ext and _SAFE_FILENAME_RE.match(ext) and len(ext) <= 12:
base, extension = stem, f".{ext}"
else:
base, extension = candidate, ""
# Replace any character outside the allowlist with underscore.
base = re.sub(r"[^A-Za-z0-9._\-]", "_", base)
base = base.strip("._-") or "file"
if base.upper().split(".")[0] in _WINDOWS_RESERVED_NAMES:
base = f"_{base}"
safe = (base + extension)[:128]
return safe or None
def _generate_join_code(length: int = 6) -> str:
"""Human-friendly course invite code: 6 chars, avoids ambiguous ones.
Uses ``secrets.choice`` to make codes unguessable by timing/random oracles
(L2 from the security audit) — still short enough to dictate over the phone.
"""
import secrets
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I for readability
return "".join(secrets.choice(alphabet) for _ in range(length))
def _generate_unique_join_code(
conn: sqlite3.Connection, *, exclude_course_id: int | None = None
) -> str:
"""Return a fresh join-code that doesn't collide with any existing course.
The alphabet gives 32^6 ≈ 10⁹ possibilities, so collisions are vanishingly
rare — but we still retry a few times so the rare case can't corrupt DB
invariants by writing a duplicate.
"""
for _ in range(8):
candidate = _generate_join_code()
if exclude_course_id is None:
hit = conn.execute("SELECT 1 FROM course WHERE join_code=?", (candidate,)).fetchone()
else:
hit = conn.execute(
"SELECT 1 FROM course WHERE join_code=? AND id <> ?",
(candidate, exclude_course_id),
).fetchone()
if not hit:
return candidate
return candidate # extremely unlikely — best effort, caller handles collision
def list_teacher_courses(conn: sqlite3.Connection, teacher_id: int) -> list[dict]:
rows = conn.execute(
"SELECT c.id, c.title, c.updated_at, c.join_code, c.subject_area, c.grade_level, "
" (SELECT COUNT(*) FROM enrollment WHERE course_id = c.id) AS students_cnt, "
" (SELECT COUNT(*) FROM assignment WHERE course_id = c.id) AS assignments_cnt, "
" (SELECT COUNT(*) FROM submission s JOIN assignment a ON a.id = s.assignment_id "
" WHERE a.course_id = c.id AND s.status = 'submitted') AS pending_cnt "
"FROM course c WHERE c.teacher_id = ? ORDER BY c.updated_at DESC",
(teacher_id,),
).fetchall()
return [
{
"id": r[0],
"title": r[1],
"updated_at": r[2],
"join_code": r[3],
"subject_area": r[4] or "general",
"grade_level": r[5],
"students": r[6] or 0,
"assignments": r[7] or 0,
"pending": r[8] or 0,
}
for r in rows
]
def list_student_courses(conn: sqlite3.Connection, student_id: int) -> list[dict]:
rows = conn.execute(
"SELECT c.id, c.title, c.updated_at, u.display_name AS teacher_name, "
" c.subject_area, c.grade_level, "
" (SELECT COUNT(*) FROM assignment WHERE course_id = c.id) AS assignments_cnt "
"FROM enrollment e JOIN course c ON c.id = e.course_id "
"LEFT JOIN user u ON u.id = c.teacher_id "
"WHERE e.student_id = ? ORDER BY c.updated_at DESC",
(student_id,),
).fetchall()
return [
{
"id": r[0],
"title": r[1],
"updated_at": r[2],
"teacher_name": r[3],
"subject_area": r[4] or "general",
"grade_level": r[5],
"assignments": r[6] or 0,
}
for r in rows
]
def create_course(
conn: sqlite3.Connection,
*,
teacher_id: int,
title: str,
subject_area: str = "general",
grade_level: str | None = None,
) -> dict:
clean_title = (title or "").strip() or "Новый курс"
subject = normalize_domain(subject_area)
grade = (grade_level or "").strip()[:40] or None
join_code = _generate_unique_join_code(conn)
cursor = conn.execute(
"INSERT INTO course(json, updated_at, teacher_id, title, join_code, subject_area, grade_level) "
"VALUES(?,?,?,?,?,?,?)",
(
json.dumps(empty_course(), ensure_ascii=False),
now_iso(),
teacher_id,
clean_title,
join_code,
subject,
grade,
),
)
conn.commit()
course_id = int(cursor.lastrowid or 0)
return {
"id": course_id,
"title": clean_title,
"join_code": join_code,
"subject_area": subject,
"grade_level": grade,
}
def get_course_subject_area(conn: sqlite3.Connection, course_id: int) -> str:
row = conn.execute("SELECT subject_area FROM course WHERE id=?", (course_id,)).fetchone()
return str(row[0]) if row and row[0] else "general"
def get_course_owner(conn: sqlite3.Connection, course_id: int) -> int | None:
row = conn.execute("SELECT teacher_id FROM course WHERE id=?", (course_id,)).fetchone()
if not row:
return None
return int(row[0]) if row[0] is not None else None
def is_enrolled(conn: sqlite3.Connection, course_id: int, student_id: int) -> bool:
row = conn.execute(
"SELECT 1 FROM enrollment WHERE course_id=? AND student_id=?",
(course_id, student_id),
).fetchone()
return row is not None
def join_course_by_code(conn: sqlite3.Connection, *, student_id: int, code: str) -> dict | None:
clean = (code or "").strip().upper()
if not clean:
return None
row = conn.execute("SELECT id, title FROM course WHERE join_code=?", (clean,)).fetchone()
if not row:
return None
course_id, title = int(row[0]), row[1]
if not is_enrolled(conn, course_id, student_id):
conn.execute(
"INSERT INTO enrollment(course_id, student_id, created_at) VALUES(?,?,?)",
(course_id, student_id, now_iso()),
)
conn.commit()
return {"id": course_id, "title": title}
_PPTX_MAX_SLIDE_XML_BYTES = 8 * 1024 * 1024 # 8 MiB per slide is generous; real slides are <100 KiB
_PPTX_MAX_SLIDES = 1024
def extract_text_from_pptx(path: Path):
"""Extract slide text from a `.pptx` file.
Defensive bounds:
* Reject zip-bombed slides whose decompressed XML exceeds
``_PPTX_MAX_SLIDE_XML_BYTES`` — a real slide is well under 1 MiB; an 8 MiB
ceiling has plenty of headroom while preventing a single malicious slide
from forcing the parser to allocate gigabytes.
* Cap the number of slides parsed to ``_PPTX_MAX_SLIDES`` so a deck with
a million entries cannot exhaust memory just from name iteration.
* Python 3.7+ ``xml.etree.ElementTree`` already disables external entity
resolution by default — we don't add ``defusedxml`` to keep the
stdlib-first dependency story, but the size cap above bounds the worst
case for quadratic-blowup ("billion laughs") payloads.
"""
texts: list[str] = []
with zipfile.ZipFile(path, "r") as z:
slide_files = sorted(
[f for f in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", f)],
key=lambda s: int(re.search(r"slide(\d+)\.xml$", s).group(1)),
)[:_PPTX_MAX_SLIDES]
for sf in slide_files:
info = z.getinfo(sf)
if info.file_size > _PPTX_MAX_SLIDE_XML_BYTES:
# Skip oversized slides instead of failing the whole upload —
# caller will see a shorter extraction and the warnings list
# in extract_runtime_text will surface this.
continue
xml_data = z.read(sf)
root = ET.fromstring(xml_data)
for t in root.iter():
if t.tag.endswith("}t") and t.text:
texts.append(t.text)
return "\n".join(texts)
def canonical_type_for_upload(path: Path) -> str | None:
extension = path.suffix.lower()
if extension in {".txt", ".md"}:
return "txt"
if extension in {".pdf", ".doc", ".docx"}:
return extension.lstrip(".")
if extension == ".xlsx":
return "xlsx"
if extension == ".xlsb":
return "xlsb"
if extension in {".png", ".jpg", ".jpeg", ".tiff", ".tif", ".webp"}:
return "image"
return None
def join_extracted_text(result: StructuredExtractionResult) -> str:
parts: list[str] = []
for fragment in result.text_fragments:
text = fragment.content.strip()
if not text and fragment.kind == "sheet":
cells = fragment.metadata.get("cells", [])
sheet_name = fragment.metadata.get("sheet_name", "")
cell_lines = [
f"{c['cell_address']}: {c['display_value']}"
for c in cells
if isinstance(c, dict) and c.get("display_value")
]
if cell_lines:
text = f"[Sheet: {sheet_name}]\n" + "\n".join(cell_lines)
if text:
parts.append(text)
return "\n\n".join(parts)
def _run_ocr_for_fragment(fragment, raw_payload: bytes | None) -> str:
"""Return OCR text for a single fragment, or empty string if unavailable."""
if _OCR_PORT is None or raw_payload is None:
return ""
port = _OCR_PORT
img_bytes: bytes
img_suffix: str
if fragment.kind == "pdf_page":
from prepodov_ai.ingestion.pdf_processing import PdfProcessingService
page_number = int(fragment.metadata.get("page_number", 1))
try:
img_bytes = PdfProcessingService().render_page_image(raw_payload, page_number)
except Exception as exc:
sys.stderr.write(f"[ocr] render failed for page {page_number}: {exc}\n")
return ""
img_suffix = ".png"
elif fragment.kind == "image":
img_bytes = raw_payload
mime = str(fragment.metadata.get("mime", "image/png"))
img_suffix = _MIME_TO_SUFFIX.get(mime, ".png")
else:
return ""
with tempfile.NamedTemporaryFile(suffix=img_suffix, delete=False) as tmp:
tmp.write(img_bytes)
tmp_path = tmp.name
try:
result = port.extract_text(Path(tmp_path))
return result.text.strip()
except Exception as exc:
sys.stderr.write(f"[ocr] tesseract failed: {exc}\n")
return ""
finally:
Path(tmp_path).unlink(missing_ok=True)
def extract_runtime_text(path: Path) -> RuntimeExtraction:
"""Extract user-visible text through the production ingestion router.
PPTX is still handled by the legacy XML parser because the ingestion router does not own
presentation parsing yet. All other supported upload types share the production extraction
services so runtime behavior matches the ingestion contract and exposes actionable warnings.
"""
extension = path.suffix.lower()
if extension == ".pptx":
text = extract_text_from_pptx(path)
warnings = [] if text.strip() else ["pptx_no_text_found"]
return RuntimeExtraction(
text=text,
warnings=warnings,
fallback_trace=["parser:pptx:xml"],
capabilities_used=["pptx_xml_text"],
)
canonical_type = canonical_type_for_upload(path)
if canonical_type is None:
return RuntimeExtraction(
text="",
warnings=[f"unsupported_upload_type:{extension or 'none'}"],
fallback_trace=[],
capabilities_used=[],
)
raw_payload = path.read_bytes()
result = EXTRACTION_ROUTER.extract(
canonical_type=canonical_type,
filename=path.name,
payload=raw_payload,
)
all_fragments = [*result.text_fragments, *result.image_fragments]
ocr_texts: list[str] = []
ocr_applied = False
if _OCR_PORT is not None:
ocr_raw = raw_payload if canonical_type in {"pdf", "image"} else None
for fragment in all_fragments:
if fragment.requires_ocr:
ocr_text = _run_ocr_for_fragment(fragment, ocr_raw)
if ocr_text:
ocr_texts.append(ocr_text)
ocr_applied = True
base_text = join_extracted_text(result)
full_text = base_text + ("\n\n" + "\n\n".join(ocr_texts) if ocr_texts else "")
requires_ocr = any(f.requires_ocr for f in all_fragments)
if _OCR_PORT is None and requires_ocr:
ocr_warning = ["ocr_unavailable_tesseract_missing"]
elif _OCR_PORT is not None and requires_ocr and not ocr_applied:
ocr_warning = ["ocr_no_text_extracted"]
else:
ocr_warning = []
return RuntimeExtraction(
text=full_text,
warnings=result.warnings + ocr_warning,
fallback_trace=result.fallback_trace,
capabilities_used=result.capabilities_used + (["ocr_tesseract"] if ocr_applied else []),
requires_ocr=requires_ocr,
ocr_applied=ocr_applied,
)
def parse_content_disposition(value: str):
out = {}
if not value:
return out
parts = [p.strip() for p in value.split(";")]
for p in parts:
if "=" in p:
k, v = p.split("=", 1)
out[k.strip().lower()] = v.strip().strip('"')
else:
out[p.lower()] = True
return out
def parse_multipart_formdata(content_type: str, body: bytes):
boundary = None
for piece in content_type.split(";"):
piece = piece.strip()
if piece.startswith("boundary="):
boundary = piece.split("=", 1)[1].strip().strip('"')
if not boundary:
return []
bnd = ("--" + boundary).encode()
parts = body.split(bnd)
result = []
for part in parts:
part = part.strip(b"\r\n")
if not part or part == b"--":
continue
header_end = part.find(b"\r\n\r\n")
if header_end < 0:
continue
try:
headers_raw = part[:header_end].decode("utf-8")
except UnicodeDecodeError:
headers_raw = part[:header_end].decode("latin1", errors="replace")
content = part[header_end + 4 :]
if content.endswith(b"\r\n"):
content = content[:-2]
headers = {}
for line in headers_raw.split("\r\n"):
if ":" in line:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
disp = parse_content_disposition(headers.get("content-disposition", ""))
result.append(
{
"name": disp.get("name"),
"filename": disp.get("filename"),
"headers": headers,
"content": content,
}
)
return result
def extract_json(text: str):
if not text:
return None
try:
return json.loads(text)
except Exception:
pass
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start : end + 1])
except Exception:
return None
return None
def generate_course_from_text(raw_text: str):
"""Return ``(course_dict, rubric_by_lesson, description_by_lesson, fallback_used)``."""
if LLM_CLIENT is None:
return load_sample_course(), {}, {}, True
prompt = build_course_prompt(raw_text[:MAX_LLM_INPUT_CHARS])
try:
response = LLM_CLIENT.generate(
LLMRequest(prompt=prompt, max_input_chars=MAX_LLM_INPUT_CHARS)
)
data = response.data
sys.stderr.write(
f"[llm] backend={response.backend} "
f"in={response.input_tokens} out={response.output_tokens}\n"
)
except LLMError as exc:
sys.stderr.write(f"[llm] generate_course_from_text failed: {exc}\n")
return load_sample_course(), {}, {}, True
if data and "course" in data:
rubric_by_lesson = data.get("rubric_by_lesson", {}) or {}
description_by_lesson = data.get("description_by_lesson", {}) or {}
return data, rubric_by_lesson, description_by_lesson, False
return load_sample_course(), {}, {}, True
def generate_feedback_draft(assignment_title, rubric, submission_text):
"""Backward-compatible feedback draft — returns a string.
Kept for tests and external callers. Uses the single-call fallback path.
"""
if LLM_CLIENT is None:
return _generic_feedback_draft()
prompt = build_feedback_prompt(assignment_title, rubric, submission_text[:MAX_LLM_INPUT_CHARS])
try:
response = LLM_CLIENT.generate(
LLMRequest(
prompt=prompt,
response_format="text",
max_input_chars=MAX_LLM_INPUT_CHARS,
)
)
if response.raw_text:
return "(AI draft)\n" + response.raw_text.strip()
except LLMError as exc:
sys.stderr.write(f"[llm] generate_feedback_draft failed: {exc}\n")
return _generic_feedback_draft()
def generate_feedback_draft_chunked(
assignment_title,
rubric,
submission_text,
*,
assignment_description: str = "",
domain: str = "general",
) -> tuple[str, dict]:
"""Chunked feedback draft with metadata (chunk count, synthesis flag).
Used by HTTP handlers so the UI can explain to the teacher how the draft
was assembled. Returns ``(content, meta)``.
"""
if LLM_CLIENT is None:
return _generic_feedback_draft(), {
"chunk_count": 0,
"synthesis_used": False,
"fallback_used": True,
}
options = ChunkingOptions(
chunk_chars=cfg.feedback_chunk_chars,
overlap_chars=cfg.feedback_chunk_overlap,
parallel=cfg.feedback_parallel,
enable_synthesis=cfg.feedback_enable_synthesis,
domain=domain or "general",
llm_timeout_seconds=cfg.llm_timeout_seconds,
max_input_chars=MAX_LLM_INPUT_CHARS,
)
try:
result = generate_chunked_feedback(
LLM_CLIENT,
assignment_title=assignment_title,
assignment_description=assignment_description or "",
rubric=rubric,
submission_text=submission_text,
options=options,
)
except LLMError as exc:
sys.stderr.write(f"[llm] chunked feedback failed: {exc}\n")
return _generic_feedback_draft(), {
"chunk_count": 0,
"synthesis_used": False,
"fallback_used": True,
"error": str(exc),
}
meta = {
"chunk_count": result.chunk_count,
"synthesis_used": result.synthesis_used,
"fallback_used": result.fallback_used,
}
content = result.content
if not content or not content.strip():
return _generic_feedback_draft(), {**meta, "fallback_used": True}
if not content.startswith("(AI draft)"):
content = "(AI draft)\n" + content
return content, meta
def _generic_feedback_draft() -> str:
return (
"(AI draft)\n"
"Сильные стороны: видны базовые попытки решить задачу.\n"
"Замечания: требуется больше точности и полноты по критериям.\n"
"Что улучшить: ответить на все пункты задания и структурировать решение.\n"
"Следующий шаг: доработать ключевые элементы и привести примеры."
)
def _write_security_headers(handler) -> None:
"""Common defensive headers applied to every response.
Header set documented:
* ``X-Content-Type-Options: nosniff`` — disable MIME sniffing.
* ``X-Frame-Options: DENY`` — legacy clickjacking guard (modern equivalent
is the ``frame-ancestors 'none'`` CSP directive below).
* ``Referrer-Policy`` — strict, send full URL only on same-origin.
* ``Content-Security-Policy`` — strict allow-list of script/style/image
sources; ``style-src`` allows inline because the design system inlines
a handful of small style hints; scripts must come from /static.
* ``Cross-Origin-Opener-Policy: same-origin`` — isolates this window
from cross-origin opener pages (closes window.opener attack surface).
* ``Cross-Origin-Embedder-Policy: credentialless`` — modern default that
lets us embed cross-origin resources only without credentials. Safe
because the app loads zero cross-origin assets at runtime.
* ``Cross-Origin-Resource-Policy: same-origin`` — only same-origin pages
may embed any resource we serve.
* ``Permissions-Policy`` — explicitly disable browser APIs we don't use
(camera, microphone, geolocation, payments, sensors, USB). Even if a
page is hijacked, these capabilities aren't reachable.
"""
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("X-Frame-Options", "DENY")
handler.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
handler.send_header("Cross-Origin-Opener-Policy", "same-origin")
handler.send_header("Cross-Origin-Embedder-Policy", "credentialless")
handler.send_header("Cross-Origin-Resource-Policy", "same-origin")
handler.send_header(
"Permissions-Policy",
(
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
"encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), "
"magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), "
"publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), "
"xr-spatial-tracking=()"
),
)
rid = REQUEST_ID.get()
if rid and rid != "-":
handler.send_header("X-Request-Id", rid)
# CSP: self + inline styles (needed for CSS file + potential design tweaks);
# no inline scripts, no object embedding, no cross-origin form submission.
handler.send_header(
"Content-Security-Policy",
(
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"base-uri 'none'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'"
),
)
def json_response(handler, payload, status=200, *, extra_headers: dict | None = None):
"""Serialize ``payload`` as JSON and write the HTTP response.
``extra_headers`` lets callers append response-specific headers (e.g. the
``Retry-After`` hint for 503 responses) without reaching into low-level
``BaseHTTPRequestHandler`` methods.
"""
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(data)))
_write_security_headers(handler)
for name, value in (extra_headers or {}).items():
handler.send_header(str(name), str(value))
for cookie in getattr(handler, "_extra_cookies", []) or []:
handler.send_header("Set-Cookie", cookie)
handler.end_headers()
handler.wfile.write(data)
# Static assets that benefit from short-lived browser caching. HTML and the
# index page are intentionally NOT cached so users always see the freshest
# auth/onboarding flow when we push a fix; CSS/JS/SVG/images are bumped via
# query string when needed. ``no-cache`` forces revalidation but allows ETag.
_CACHEABLE_STATIC_EXTENSIONS = frozenset({".css", ".js", ".svg", ".png", ".jpg", ".jpeg", ".ico"})
_STATIC_CACHE_MAX_AGE = 60 * 60 # 1 hour in seconds — small enough to ship fast fixes
_GZIPPABLE_CONTENT_TYPES = (
"text/",
"application/javascript",
"application/json",
"image/svg+xml",
)
_GZIP_MIN_BYTES = 1024
def _accepts_gzip(handler) -> bool:
accept = (handler.headers.get("Accept-Encoding") or "").lower()
return "gzip" in accept
def _maybe_gzip(content: bytes, content_type: str, handler) -> tuple[bytes, str | None]:
"""Return (body, content-encoding) — gzip-compress if it makes sense.
Skips small bodies (the CPU+overhead of gzip rarely beats raw transfer
under 1 KiB) and content types that are already compressed (PNG/JPEG).
"""
if len(content) < _GZIP_MIN_BYTES:
return content, None
if not _accepts_gzip(handler):
return content, None
if not any(content_type.startswith(prefix) for prefix in _GZIPPABLE_CONTENT_TYPES):
return content, None
import gzip
compressed = gzip.compress(content, compresslevel=6)
# Only use the compressed body if it's actually smaller (rare edge case).
if len(compressed) >= len(content):
return content, None
return compressed, "gzip"
def file_response(handler, path: Path):
if not path.exists() or not path.is_file():
handler.send_error(404)
return
content = path.read_bytes()
ext = path.suffix.lower()
content_type = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
}.get(ext, "application/octet-stream")
body, encoding = _maybe_gzip(content, content_type, handler)
handler.send_response(200)
handler.send_header("Content-Type", content_type)
handler.send_header("Content-Length", str(len(body)))
if encoding:
handler.send_header("Content-Encoding", encoding)
handler.send_header("Vary", "Accept-Encoding")
if ext in _CACHEABLE_STATIC_EXTENSIONS:
handler.send_header(
"Cache-Control",
f"public, max-age={_STATIC_CACHE_MAX_AGE}, stale-while-revalidate=60",
)
else:
# HTML and unknown types: never cache — keeps shell pages always fresh.
handler.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
_write_security_headers(handler)
handler.end_headers()
handler.wfile.write(body)
MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 # 1 MB default cap for JSON endpoints
_LOGIN_ATTEMPT_BUCKETS: dict[str, list[float]] = {}
_LOGIN_ATTEMPT_WINDOW = 60.0 * 15 # 15 minutes
_LOGIN_ATTEMPT_LIMIT = 8
# A fixed dummy hash so the login path always pays a scrypt cost regardless of
# whether the email existed — blunts timing-based user-enumeration.
_DUMMY_PASSWORD_HASH_CACHE: str | None = None
def _dummy_password_hash() -> str:
"""Return a cached scrypt-format hash to run verify against on unknown emails."""
global _DUMMY_PASSWORD_HASH_CACHE
if _DUMMY_PASSWORD_HASH_CACHE is None:
from prepodov_ai.webauth.passwords import hash_password
_DUMMY_PASSWORD_HASH_CACHE = hash_password("dummy-placeholder-timing-only")
return _DUMMY_PASSWORD_HASH_CACHE
class Handler(BaseHTTPRequestHandler):
# ``BaseHTTPRequestHandler`` defaults ``Server: BaseHTTP/0.6 Python/X.Y.Z``
# which leaks both the framework and Python version. Override to a single
# opaque token. (Security scanners flag the default as info disclosure.)
server_version = "PrepodovAI"
sys_version = ""
def version_string(self) -> str:
return self.server_version
def send_error(self, code, message=None, explain=None): # type: ignore[override]
"""Wrap stdlib ``send_error`` so error responses still carry our
security headers. The default 404/500 paths bypass ``json_response``
and ``file_response``, so without this override the security scanner
rightly reports headers missing on /robots.txt, /sitemap.xml, etc.
"""
try:
short, long = self.responses.get(code, ("???", "???"))
except Exception:
short, long = "Error", str(code)
if message is None:
message = short
body = (explain or long or message or "").encode("utf-8", "replace")
self.send_response(code, message)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
# Match production posture for 404/500 — no caches in front of error
# bodies, and never carry stale state between users.
self.send_header("Cache-Control", "no-store")
_write_security_headers(self)
self.end_headers()
if self.command != "HEAD" and code >= 200 and code not in (204, 304):
self.wfile.write(body)
# ---------- Auth helpers ----------
def _is_secure_request(self) -> bool:
# Behind a reverse proxy: trust X-Forwarded-Proto if explicitly set.
proto = (self.headers.get("X-Forwarded-Proto") or "").lower()
return proto == "https"
def _current_session_token(self) -> str | None:
return parse_session_cookie(self.headers.get("Cookie"))
def _client_ip(self) -> str:
forwarded = (self.headers.get("X-Forwarded-For") or "").strip()
if forwarded:
# Last hop is closest to us; earlier hops are client-controlled.
return forwarded.split(",")[-1].strip() or self.client_address[0]
return self.client_address[0]
def _check_login_rate_limit(self) -> bool:
"""Return True if the caller is allowed to attempt login."""
import time
ip = self._client_ip()
now = time.time()
bucket = _LOGIN_ATTEMPT_BUCKETS.get(ip, [])
bucket = [t for t in bucket if now - t < _LOGIN_ATTEMPT_WINDOW]
if len(bucket) >= _LOGIN_ATTEMPT_LIMIT:
_LOGIN_ATTEMPT_BUCKETS[ip] = bucket
return False
bucket.append(now)
_LOGIN_ATTEMPT_BUCKETS[ip] = bucket
return True
def _current_user(self, conn: sqlite3.Connection | None = None) -> dict | None:
"""Resolve the authenticated user from the session cookie, or ``None``.
Silently returns ``None`` for anonymous / expired / invalid sessions —
callers choose whether to serve a legacy public response or respond
with 401. Use ``_require_user`` when the endpoint is strictly private.
"""
token = self._current_session_token()
if not token:
return None
owns_conn = conn is None
if conn is None:
conn = connect_db()
try:
return resolve_session(conn, token)
finally:
if owns_conn:
conn.close()
def _require_user(self, *, role: str | None = None) -> dict | None:
"""Return the authenticated user dict or send 401/403 and return None."""
user = self._current_user()
if not user:
json_response(self, {"error": "authentication required"}, status=401)
return None
if role and user.get("role") != role and user.get("role") != "admin":
json_response(self, {"error": f"role {role!r} required"}, status=403)
return None
return user
def _read_json_body(self, max_bytes: int = MAX_JSON_BODY_BYTES) -> dict | None:
length_raw = self.headers.get("Content-Length", "0")
try:
length = int(length_raw)
except (TypeError, ValueError):
json_response(self, {"error": "invalid Content-Length"}, status=400)
return None
if length < 0:
json_response(self, {"error": "invalid Content-Length"}, status=400)
return None
if length > max_bytes:
json_response(self, {"error": f"payload too large (max {max_bytes} bytes)"}, status=413)
return None
if not length:
return {}
body = self.rfile.read(length).decode("utf-8")
if not body.strip():
return {}
try:
data = json.loads(body)
except json.JSONDecodeError:
json_response(self, {"error": "invalid JSON"}, status=400)
return None
if not isinstance(data, dict):
json_response(self, {"error": "JSON object expected"}, status=400)
return None
return data
# ---------- Course scope ----------
def _resolve_course_scope(self, conn: sqlite3.Connection, user: dict | None) -> int | None:
"""Pick the course this request is scoped to.
Precedence: ``X-Course-Id`` header → ``course_id`` query param →
(authenticated only) user's first available course. Falls back to the
legacy singleton course (id=1) so pre-auth callers and existing tests
continue to work exactly as before.
Returns None if the user explicitly requested a course they cannot
access — after responding with 403. Callers must check.
"""
requested_raw: str | None = None
header_value = self.headers.get("X-Course-Id")
if header_value:
requested_raw = header_value.strip()
if requested_raw is None:
parsed = urlparse(self.path)
query_values = parse_qs(parsed.query or "").get("course_id")
if query_values:
requested_raw = query_values[0]
if requested_raw:
try:
requested = int(requested_raw)
except (TypeError, ValueError):
json_response(self, {"error": "invalid course_id"}, status=400)
return None
if not self._assert_course_access(conn, user, requested):
return None
return requested
# Legacy fallback: singleton course when no explicit scope is provided.
return LEGACY_COURSE_ID
def _assert_course_access(
self, conn: sqlite3.Connection, user: dict | None, course_id: int
) -> bool:
"""Return True if ``user`` may read/write data inside ``course_id``.
Teachers must own the course; students must be enrolled. Admin role
bypasses ownership. Writes 403/404 and returns False on failure.
"""
if course_id == LEGACY_COURSE_ID:
# Singleton is shared / legacy — always accessible.
return True
owner = get_course_owner(conn, course_id)
if owner is None:
json_response(self, {"error": "course not found"}, status=404)
return False
role = (user or {}).get("role")
user_id = (user or {}).get("id")
if role == "admin":
return True
if role == "teacher" and owner == user_id:
return True
if role == "student" and user_id is not None and is_enrolled(conn, course_id, user_id):
return True
json_response(self, {"error": "forbidden"}, status=403)
return False
def _set_session_cookie(self, token: str, expires_at) -> None:
self._extra_cookies = getattr(self, "_extra_cookies", [])
self._extra_cookies.append(
format_set_cookie(token, expires_at, secure=self._is_secure_request())
)
def _clear_session_cookie(self) -> None:
self._extra_cookies = getattr(self, "_extra_cookies", [])
self._extra_cookies.append(format_expire_cookie(secure=self._is_secure_request()))
def _safe(self, fn):
"""Run a request method; turn any uncaught error into a 500 without leaking details.
Also manages per-request context: assigns (or honours the client-supplied)
``X-Request-Id`` so every log line, exception trace and 5xx response
body can be correlated during incident response.
"""
import secrets as _secrets
incoming = self.headers.get("X-Request-Id") if self.headers else None
rid = (incoming or _secrets.token_urlsafe(8))[:24]
token = REQUEST_ID.set(rid)
try:
return fn()
except BrokenPipeError:
# Client disconnected — logging is noisy, the response is moot.
return None
except Exception:
log.exception("unhandled exception in request handler")
try:
return json_response(
self,
{"error": "internal error", "request_id": rid},
status=500,
)
except Exception:
return None
finally:
REQUEST_ID.reset(token)
def do_GET(self):
return self._safe(self._do_GET)
def do_POST(self):
if not self._origin_check():
return
return self._safe(self._do_POST)
def do_PATCH(self):
if not self._origin_check():
return
return self._safe(self._do_PATCH)
def do_DELETE(self):
if not self._origin_check():
return
return self._safe(self._do_DELETE)
def _origin_check(self) -> bool:
"""Reject mutating requests whose Origin/Referer points off-host.
SameSite=Lax already blocks the common cross-site CSRF surface, but an
explicit Origin/Referer guard is cheap defence-in-depth: it covers
clients that haven't shipped strict SameSite, mis-configured proxies,
and browser-extension-mediated requests. Same-origin requests have
Origin == request URL host (or no Origin and a Referer that matches);
anything else is rejected with 403.
Skip the check on auth endpoints — login/register pages are reachable
cold and will set Origin to the same host once posted from /login.
Skip when the runtime hasn't received any host header at all (CLI
smoke tests, unit-test fixtures).
"""
host_header = (self.headers.get("Host") or "").strip().lower()
if not host_header:
return True
origin = (self.headers.get("Origin") or "").strip()
referer = (self.headers.get("Referer") or "").strip()
if not origin and not referer:
# Some clients (curl, scripts, CLI tools) send neither. Allow —
# SameSite=Lax cookie won't be carried by a malicious form post
# in any modern browser anyway.
return True
try:
from urllib.parse import urlparse as _urlparse
for raw in (origin, referer):
if not raw:
continue
parsed = _urlparse(raw)
if parsed.netloc.lower() == host_header:
return True
except Exception:
return True
log.warning(
"rejected cross-origin mutating request: method=%s path=%s origin=%r referer=%r host=%r",
self.command,
self.path,
origin,
referer,
host_header,
)
try:
json_response(self, {"error": "cross-origin request rejected"}, status=403)
except Exception:
pass
return False
def do_HEAD(self):
# Minimal HEAD: let the standard Handler probe static files and return
# headers only. For /api/* we reuse GET and strip the body.
return self._safe(self._do_HEAD)
def do_OPTIONS(self):
return self._safe(self._do_OPTIONS)
def _do_HEAD(self): # noqa: N802 - mirrors BaseHTTPRequestHandler.do_HEAD casing
# Re-use GET's response headers; discard the body.
parsed = urlparse(self.path)
path = parsed.path
if path == "/api/auth/me":
user = self._current_user()
self.send_response(204 if user else 401)
_write_security_headers(self)
self.end_headers()
return
# For any other HEAD, defer to a lightweight 200 with security headers.
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
_write_security_headers(self)
self.end_headers()
def _do_OPTIONS(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing
"""Advertise allowed methods per endpoint class.
Kept simple (no CORS yet — same-origin deployment). Helps API explorers
and lets clients probe availability.
"""
parsed = urlparse(self.path)
path = parsed.path
if path.startswith("/api/auth/"):
allow = "GET, POST, PATCH, OPTIONS"
elif path.startswith("/api/courses/") or path.startswith("/api/my/"):
allow = "GET, POST, DELETE, OPTIONS"
elif path.startswith("/api/assignments/"):
allow = "GET, PATCH, OPTIONS"
elif path.startswith("/api/submissions/"):
allow = "GET, DELETE, OPTIONS"
elif path.startswith("/api/"):
allow = "GET, POST, OPTIONS"
else:
allow = "GET, OPTIONS"
self.send_response(204)
self.send_header("Allow", allow)
_write_security_headers(self)
self.end_headers()
def _do_GET(self): # noqa: N802 - mirrors BaseHTTPRequestHandler.do_GET casing
parsed = urlparse(self.path)
path = parsed.path
if path == "/":
return file_response(self, STATIC_DIR / "index.html")
if path == "/teacher":
return file_response(self, STATIC_DIR / "teacher.html")
if path == "/student":
return file_response(self, STATIC_DIR / "student.html")
if path.startswith("/static/"):
rel = path.replace("/static/", "")
safe = (STATIC_DIR / rel).resolve()
static_root = STATIC_DIR.resolve()
# Correct containment check: resolve both sides and require prefix relationship.
try:
safe.relative_to(static_root)
except ValueError:
return self.send_error(403)
return file_response(self, safe)
if path == "/api/course":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
course = get_course(conn, course_id)
finally:
conn.close()
return json_response(self, course)
if path == "/api/assignments":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
items = list_assignments(conn, course_id)
finally:
conn.close()
return json_response(self, {"assignments": items})
if path == "/api/submissions":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
# Students only see their own submissions; teachers see the whole queue.
if user and user.get("role") == "student":
items = list_submissions(conn, course_id)
items = [
s
for s in items
if s.get("student_name") == user.get("display_name")
or s.get("student_id") == user.get("id")
]
else:
items = list_submissions(conn, course_id)
finally:
conn.close()
return json_response(self, {"submissions": items})
if path == "/api/analytics":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
summary = dashboard_summary(conn, course_id)
finally:
conn.close()
return json_response(
self,
{
"total_submissions": summary["total_submissions"],
"drafts": summary["drafts"],
"finals": summary["finals"],
"avg_grade": summary["avg_grade"],
"llm_backend": LLM_BACKEND,
},
)
if path == "/api/runtime":
from prepodov_ai.llm.domains import available_domains
return json_response(
self,
{
"backend": LLM_BACKEND,
"llm_initialized": LLM_CLIENT is not None,
"gemini_model": cfg.gemini_model if LLM_BACKEND == "gemini" else None,
"openai_model": cfg.openai_model if LLM_BACKEND == "openai" else None,
"local_model": cfg.local_model_id if LLM_BACKEND == "local" else None,
"feedback": {
"chunk_chars": cfg.feedback_chunk_chars,
"chunk_overlap": cfg.feedback_chunk_overlap,
"parallel": cfg.feedback_parallel,
"synthesis_enabled": cfg.feedback_enable_synthesis,
},
"resilience": {
"max_attempts": cfg.llm_retry_max_attempts,
"breaker_threshold": cfg.llm_breaker_failure_threshold,
"breaker_reset_seconds": cfg.llm_breaker_reset_seconds,
"per_call_timeout_s": cfg.llm_per_call_timeout_seconds,
},
"submission_upload_max_bytes": cfg.submission_upload_max_bytes,
"ocr_available": _OCR_PORT is not None,
"domains": available_domains(),
},
)
if path == "/health/llm":
ok = LLM_CLIENT is not None and LLM_CLIENT.health_check()
return json_response(
self,
{
"status": "ok" if ok else "degraded",
"backend": LLM_BACKEND,
"client_initialized": LLM_CLIENT is not None,
},
status=200 if ok else 503,
)
if path == "/health/live":
# Kubernetes/Kube-style liveness probe: "is this process responsive?"
# MUST be fast and MUST NOT touch external services. A failed live
# probe triggers a pod restart, so we only fail when the runtime
# itself is broken — not when the DB or LLM is briefly unavailable.
return json_response(self, {"status": "ok", "uptime_s": _uptime_seconds()})
if path == "/health/ready":
# Readiness probe: "can we currently serve traffic?" — DB + writable
# disk. Failing this only removes us from a load-balancer; it does
# NOT restart the process. LLM health is intentionally excluded
# (transient provider outage shouldn't drop us from the LB).
db_ok = True
db_error: str | None = None
try:
with_db_retry(lambda c: c.execute("SELECT 1").fetchone(), max_attempts=1)
except Exception as exc:
db_ok = False
db_error = str(exc)[:200]
upload_ok = os.access(str(UPLOAD_DIR), os.W_OK)
ready = db_ok and upload_ok
return json_response(
self,
{
"status": "ok" if ready else "not_ready",
"db": db_ok,
"db_error": db_error,
"uploads_writable": upload_ok,
},
status=200 if ready else 503,
)
if path == "/api/student_feedback":
qs = parse_qs(parsed.query or "")
student_name = (qs.get("student_name") or [None])[0]
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
# Privacy fix: authenticated students can only see their own feedback,
# regardless of the student_name parameter. Teachers in scope can query
# any student by name (for UI search). Anonymous legacy callers still
# need the old student_name=? behaviour.
if user and user.get("role") == "student":
items = list_student_feedback(conn, student_id=user["id"], course_id=course_id)
elif user and user.get("role") in ("teacher", "admin"):
if not student_name:
return json_response(self, {"error": "student_name required"}, status=400)
items = list_student_feedback(
conn, student_name=student_name, course_id=course_id
)
else:
if not student_name:
return json_response(self, {"error": "student_name required"}, status=400)
items = list_student_feedback(
conn, student_name=student_name, course_id=course_id
)
finally:
conn.close()
return json_response(self, {"items": items})
if path == "/api/dashboard":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
summary = dashboard_summary(conn, course_id)
finally:
conn.close()
return json_response(self, summary)
if path == "/api/students":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
items = list_students(conn, course_id)
finally:
conn.close()
return json_response(self, {"students": items})
if path.startswith("/api/submissions/"):
rest = path[len("/api/submissions/") :]
if rest.isdigit():
conn = connect_db()
try:
user = self._current_user(conn)
detail = get_submission_detail(conn, int(rest))
if not detail:
return json_response(self, {"error": "not found"}, status=404)
# Scope the submission through its assignment's course.
assignment_course = conn.execute(
"SELECT course_id FROM assignment WHERE id=?",
(detail["assignment_id"],),
).fetchone()
sub_course_id = (
int(assignment_course[0]) if assignment_course else LEGACY_COURSE_ID
)
if sub_course_id != LEGACY_COURSE_ID:
if not self._assert_course_access(conn, user, sub_course_id):
return
# Students can only read their own submissions.
if user and user.get("role") == "student":
if detail.get("student_id") != user.get("id") and detail.get(
"student_name"
) != user.get("display_name"):
return json_response(self, {"error": "forbidden"}, status=403)
finally:
conn.close()
return json_response(self, detail)
# ------------------------------------------------------------------
# Auth + multi-course GET endpoints
# ------------------------------------------------------------------
if path == "/api/auth/me":
user = self._current_user()
if not user:
return json_response(self, {"authenticated": False}, status=200)
return json_response(self, {"authenticated": True, "user": user})
if path == "/api/my/courses":
user = self._require_user()
if not user:
return
conn = connect_db()
try:
if user["role"] == "teacher":
items = list_teacher_courses(conn, user["id"])
else:
items = list_student_courses(conn, user["id"])
finally:
conn.close()
return json_response(self, {"courses": items, "role": user["role"]})
if path == "/api/my/submissions":
# Students: every submission they've ever sent, across all enrolled
# courses (or plain-name legacy submissions matching display_name).
# Teachers: every submission across courses they own (scoped queue).
user = self._require_user()
if not user:
return
qs = parse_qs(parsed.query or "")
try:
limit = min(int((qs.get("limit") or ["50"])[0]), 200)
offset = max(int((qs.get("offset") or ["0"])[0]), 0)
except (TypeError, ValueError):
return json_response(self, {"error": "invalid pagination"}, status=400)
conn = connect_db()
try:
if user["role"] == "teacher":
rows = conn.execute(
"SELECT s.id, s.assignment_id, s.student_name, s.content, "
" s.created_at, s.status, a.title, a.course_id, c.title, "
" s.domain, s.attachment_name "
"FROM submission s "
"JOIN assignment a ON a.id = s.assignment_id "
"JOIN course c ON c.id = a.course_id "
"WHERE c.teacher_id = ? "
"ORDER BY s.created_at DESC LIMIT ? OFFSET ?",
(user["id"], limit, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT s.id, s.assignment_id, s.student_name, s.content, "
" s.created_at, s.status, a.title, a.course_id, c.title, "
" s.domain, s.attachment_name "
"FROM submission s "
"JOIN assignment a ON a.id = s.assignment_id "
"JOIN course c ON c.id = a.course_id "
"WHERE s.student_id = ? OR "
" (s.student_name = ? AND (c.teacher_id IS NULL OR "
" EXISTS (SELECT 1 FROM enrollment e WHERE e.course_id = c.id "
" AND e.student_id = ?))) "
"ORDER BY s.created_at DESC LIMIT ? OFFSET ?",
(
user["id"],
user["display_name"],
user["id"],
limit,
offset,
),
).fetchall()
items = [
{
"id": r[0],
"assignment_id": r[1],
"student_name": r[2],
"content": r[3],
"created_at": r[4],
"status": r[5],
"assignment_title": r[6],
"course_id": r[7],
"course_title": r[8],
"domain": r[9] or "general",
"attachment_name": r[10],
}
for r in rows
]
finally:
conn.close()
return json_response(
self,
{"submissions": items, "limit": limit, "offset": offset},
)
if path == "/login":
return file_response(self, STATIC_DIR / "login.html")
self.send_error(404)
def _do_POST(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing
parsed = urlparse(self.path)
path = parsed.path
if path == "/api/course":
data = self._read_json_body()
if data is None:
return
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
# Mutating a non-legacy course requires teacher ownership.
if course_id != LEGACY_COURSE_ID and (
not user or user.get("role") not in ("teacher", "admin")
):
return json_response(self, {"error": "forbidden"}, status=403)
save_course(conn, data, course_id=course_id)
finally:
conn.close()
return json_response(self, {"ok": True})
if path == "/api/upload_syllabus":
content_type = self.headers.get("Content-Type")
if not content_type or "multipart/form-data" not in content_type:
return json_response(self, {"error": "Expected multipart/form-data"}, status=400)
try:
length = int(self.headers.get("Content-Length", "0"))
except (TypeError, ValueError):
return json_response(self, {"error": "invalid Content-Length"}, status=400)
if length > cfg.submission_upload_max_bytes:
return json_response(
self,
{
"error": f"file exceeds limit ({length} > "
f"{cfg.submission_upload_max_bytes} bytes)"
},
status=413,
)
body = self.rfile.read(length) if length else b""
parts = parse_multipart_formdata(content_type, body)
file_part = None
for p in parts:
if p.get("name") == "file":
file_part = p
break
if not file_part or not file_part.get("filename"):
return json_response(self, {"error": "file is required"}, status=400)
original_name = str(file_part.get("filename"))
safe_name = _safe_upload_filename(original_name)
if safe_name is None:
return json_response(self, {"error": "invalid filename"}, status=400)
# Always write under a UUID prefix to prevent name collisions and
# path traversal across users (H3 from the security audit).
import uuid as _uuid
dest = UPLOAD_DIR / f"syllabus_{_uuid.uuid4().hex}_{safe_name}"
# Belt-and-braces: verify the resolved path is still inside UPLOAD_DIR.
try:
dest.resolve().relative_to(UPLOAD_DIR.resolve())
except ValueError:
return json_response(self, {"error": "invalid filename"}, status=400)
dest.write_bytes(file_part.get("content") or b"")
extracted = extract_runtime_text(dest)
return json_response(
self,
{
"filename": safe_name,
"text": extracted.text,
"warnings": extracted.warnings,
"fallback_trace": extracted.fallback_trace,
"capabilities_used": extracted.capabilities_used,
"requires_ocr": extracted.requires_ocr,
"ocr_applied": extracted.ocr_applied,
"note": (
"Текст извлечён. Проверьте предупреждения ниже, если часть материала "
"не распознана."
),
},
)
if path == "/api/generate_course":
data = self._read_json_body(max_bytes=MAX_JSON_BODY_BYTES * 4) # bigger: syllabus text
if data is None:
return
raw_text = data.get("text", "")
try:
course, rubric_by_lesson, description_by_lesson, fallback_used = (
generate_course_from_text(raw_text)
)
except Exception:
traceback.print_exc()
return json_response(self, {"error": "generation failed"}, status=500)
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
if course_id != LEGACY_COURSE_ID and (
not user or user.get("role") not in ("teacher", "admin")
):
return json_response(self, {"error": "forbidden"}, status=403)
# Critical: if the LLM bailed out and we're about to hand the
# teacher the canned sample course, DON'T overwrite a course
# they've already built. The teacher would otherwise press the
# button once, get a fallback, and lose all their work.
if fallback_used:
existing_course = get_course(conn, course_id)
existing_modules = existing_course.get("course", {}).get("modules", [])
if existing_modules:
return json_response(
self,
{
"course": existing_course,
"practice_rubrics": 0,
"practice_descriptions": 0,
"llm_backend": LLM_BACKEND,
"fallback_used": True,
"skipped_save": True,
"message": (
"Модель недоступна, а текущий курс уже содержит "
"структуру — сохранение пропущено, ваши данные целы."
),
},
)
save_course(
conn,
course,
rubric_by_lesson=rubric_by_lesson,
description_by_lesson=description_by_lesson,
course_id=course_id,
)
finally:
conn.close()
return json_response(
self,
{
"course": course,
"practice_rubrics": len(rubric_by_lesson),
"practice_descriptions": len(description_by_lesson),
"llm_backend": LLM_BACKEND,
"fallback_used": fallback_used,
},
)
if path == "/api/rubric":
data = self._read_json_body()
if data is None:
return
rubric = data.get("rubric")
if not rubric:
return json_response(self, {"error": "rubric required"}, status=400)
# Require an assignment_id so we never clobber other teachers' rubrics
# (code-review #5: previously `UPDATE assignment SET rubric_json=?` with no WHERE).
assignment_id = data.get("assignment_id")
if not assignment_id:
return json_response(
self,
{"error": "assignment_id required; use PATCH /api/assignments/{id}"},
status=400,
)
conn = connect_db()
try:
user = self._current_user(conn)
assignment_row = conn.execute(
"SELECT course_id FROM assignment WHERE id=?",
(int(assignment_id),),
).fetchone()
if not assignment_row:
return json_response(self, {"error": "assignment not found"}, status=404)
assignment_course_id = int(assignment_row[0])
if assignment_course_id != LEGACY_COURSE_ID and not self._assert_course_access(
conn, user, assignment_course_id
):
return
conn.execute(
"UPDATE assignment SET rubric_json=? WHERE id=?",
(json.dumps(rubric, ensure_ascii=False), int(assignment_id)),
)
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True})
if path == "/api/submissions":
return self._handle_create_submission()
if path == "/api/feedback/draft":
data = self._read_json_body()
if data is None:
return
submission_id = data.get("submission_id")
if not submission_id:
return json_response(self, {"error": "submission_id required"}, status=400)
conn = connect_db()
try:
user = self._current_user(conn)
sub = get_submission(conn, submission_id)
if not sub:
return json_response(self, {"error": "not found"}, status=404)
# Scope through the assignment's course — teachers (owner) only.
row = conn.execute(
"SELECT course_id FROM assignment WHERE id=?",
(sub["assignment_id"],),
).fetchone()
sub_course_id = int(row[0]) if row else LEGACY_COURSE_ID
if sub_course_id != LEGACY_COURSE_ID:
if not user or user.get("role") not in ("teacher", "admin"):
return json_response(self, {"error": "teacher only"}, status=403)
if not self._assert_course_access(conn, user, sub_course_id):
return
from prepodov_ai.llm.errors import LLMCircuitOpen
try:
draft, meta = generate_feedback_draft_chunked(
sub["assignment_title"],
sub["rubric"],
sub["content"],
assignment_description=sub.get("assignment_description") or "",
domain=(
sub.get("domain")
if sub.get("domain") and sub.get("domain") != "general"
else get_course_subject_area(conn, sub_course_id)
),
)
except LLMCircuitOpen as exc:
retry_after = max(1, int(exc.retry_after_seconds or 30))
return json_response(
self,
{
"error": "llm_unavailable",
"retry_after_seconds": retry_after,
"message": (
"Сервис обратной связи временно недоступен. "
f"Попробуйте снова через {retry_after} с."
),
},
status=503,
extra_headers={"Retry-After": retry_after},
)
except Exception:
traceback.print_exc()
return json_response(self, {"error": "feedback failed"}, status=500)
store_feedback_draft(conn, submission_id, draft)
conn.commit()
finally:
conn.close()
return json_response(self, {"content": draft, "meta": meta})
if path == "/api/feedback/final":
data = self._read_json_body()
if data is None:
return
submission_id = data.get("submission_id")
content = data.get("content", "")
try:
grade = validate_final_grade(data.get("grade"))
except ValueError as exc:
return json_response(self, {"error": str(exc)}, status=400)
if not submission_id or not content.strip():
return json_response(
self, {"error": "submission_id and content required"}, status=400
)
conn = connect_db()
try:
user = self._current_user(conn)
sub = get_submission(conn, submission_id)
if not sub:
return json_response(self, {"error": "not found"}, status=404)
row = conn.execute(
"SELECT course_id FROM assignment WHERE id=?",
(sub["assignment_id"],),
).fetchone()
sub_course_id = int(row[0]) if row else LEGACY_COURSE_ID
if sub_course_id != LEGACY_COURSE_ID:
if not user or user.get("role") not in ("teacher", "admin"):
return json_response(self, {"error": "teacher only"}, status=403)
if not self._assert_course_access(conn, user, sub_course_id):
return
store_feedback_final(conn, submission_id, content.strip(), grade)
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True})
if path == "/api/feedback/bulk_draft":
conn = connect_db()
try:
user = self._current_user(conn)
course_id = self._resolve_course_scope(conn, user)
if course_id is None:
return
if course_id != LEGACY_COURSE_ID and (
not user or user.get("role") not in ("teacher", "admin")
):
return json_response(self, {"error": "teacher only"}, status=403)
pending = conn.execute(
"SELECT s.id FROM submission s JOIN assignment a ON a.id = s.assignment_id "
"WHERE s.status='submitted' AND a.course_id = ?",
(course_id,),
).fetchall()
generated = 0
failures: list[dict] = []
course_subject = get_course_subject_area(conn, course_id)
for (submission_id,) in pending:
sub = get_submission(conn, submission_id)
if not sub:
continue
sub_domain = sub.get("domain")
resolved_domain = (
sub_domain if sub_domain and sub_domain != "general" else course_subject
)
try:
draft, _meta = generate_feedback_draft_chunked(
sub["assignment_title"],
sub["rubric"],
sub["content"],
assignment_description=sub.get("assignment_description") or "",
domain=resolved_domain,
)
store_feedback_draft(conn, submission_id, draft)
generated += 1
except Exception as exc:
failures.append({"submission_id": submission_id, "error": str(exc)[:200]})
conn.commit()
finally:
conn.close()
return json_response(
self,
{"generated": generated, "total": len(pending), "failures": failures},
)
# ------------------------------------------------------------------
# Auth + multi-course POST endpoints
# ------------------------------------------------------------------
if path == "/api/auth/register":
return self._handle_register()
if path == "/api/auth/login":
return self._handle_login()
if path == "/api/auth/logout":
return self._handle_logout()
if path == "/api/my/courses":
return self._handle_create_course()
if path == "/api/courses/join":
return self._handle_join_course()
if path.startswith("/api/courses/") and path.endswith("/rotate_code"):
course_id_str = path[len("/api/courses/") : -len("/rotate_code")]
if course_id_str.isdigit():
return self._handle_rotate_join_code(int(course_id_str))
self.send_error(404)
# --- Auth endpoint handlers --------------------------------------------
def _handle_register(self):
data = self._read_json_body()
if data is None:
return
conn = connect_db()
try:
user = create_user(
conn,
email=data.get("email", ""),
password=data.get("password", ""),
display_name=data.get("display_name", ""),
role=data.get("role", "student"),
)
token, expires_at = create_session(conn, user["id"])
conn.commit()
except UserAlreadyExistsError as exc:
conn.close()
return json_response(self, {"error": str(exc)}, status=409)
except (ValidationError, PasswordError) as exc:
conn.close()
return json_response(self, {"error": str(exc)}, status=400)
finally:
try:
conn.close()
except Exception:
pass
self._set_session_cookie(token, expires_at)
return json_response(self, {"user": user}, status=201)
def _handle_login(self):
if not self._check_login_rate_limit():
return json_response(
self,
{"error": "too many login attempts, try again in 15 minutes"},
status=429,
)
data = self._read_json_body()
if data is None:
return
email = data.get("email", "")
password = data.get("password", "")
conn = connect_db()
try:
user = find_user_by_email(conn, email)
# Run verify_password against a dummy hash when the user doesn't exist,
# so response timing doesn't leak whether the email is registered (L5).
candidate_hash = user["password_hash"] if user else _dummy_password_hash()
if not verify_password(password, candidate_hash) or not user:
return json_response(self, {"error": "invalid credentials"}, status=401)
# Rotate: invalidate any cookie the client might already have presented
# so the new login owns a fresh session token (M1 — session fixation).
previous_token = self._current_session_token()
if previous_token:
delete_session(conn, previous_token)
token, expires_at = create_session(conn, user["id"])
conn.commit()
public = {k: user[k] for k in ("id", "email", "role", "display_name")}
finally:
conn.close()
self._set_session_cookie(token, expires_at)
return json_response(self, {"user": public})
def _handle_logout(self):
token = self._current_session_token()
if token:
conn = connect_db()
try:
delete_session(conn, token)
finally:
conn.close()
self._clear_session_cookie()
return json_response(self, {"ok": True})
# --- Course management --------------------------------------------------
def _handle_create_course(self):
user = self._require_user(role="teacher")
if not user:
return
data = self._read_json_body()
if data is None:
return
conn = connect_db()
try:
course = create_course(
conn,
teacher_id=user["id"],
title=data.get("title", ""),
subject_area=data.get("subject_area", "general"),
grade_level=data.get("grade_level"),
)
finally:
conn.close()
return json_response(self, {"course": course}, status=201)
def _handle_join_course(self):
user = self._require_user(role="student")
if not user:
return
data = self._read_json_body()
if data is None:
return
conn = connect_db()
try:
course = join_course_by_code(conn, student_id=user["id"], code=data.get("code", ""))
finally:
conn.close()
if not course:
return json_response(self, {"error": "invalid join code"}, status=404)
return json_response(self, {"course": course})
def _handle_rotate_join_code(self, course_id: int):
user = self._require_user(role="teacher")
if not user:
return
conn = connect_db()
try:
if not self._assert_course_owner(conn, user, course_id):
return
new_code = _generate_unique_join_code(conn, exclude_course_id=course_id)
conn.execute("UPDATE course SET join_code=? WHERE id=?", (new_code, course_id))
conn.commit()
finally:
conn.close()
return json_response(self, {"join_code": new_code})
def _handle_create_submission(self):
try:
return self._handle_create_submission_inner()
except Exception:
traceback.print_exc()
return json_response(self, {"error": "submission failed"}, status=500)
def _current_student_identity(self, conn, posted_name: str | None) -> tuple[str, int | None]:
"""Resolve (student_name, student_id) for a submission.
Authenticated students get their own display_name and id — the client
cannot impersonate another student by sending a different
``student_name`` (audit L1). Anonymous callers keep the legacy
free-text name path.
"""
user = self._current_user(conn)
if user and user.get("role") == "student":
return (user["display_name"], user["id"])
return (normalize_student_name(posted_name or "Student"), None)
def _assignment_course_id(self, conn, assignment_id: int) -> int | None:
row = conn.execute(
"SELECT course_id FROM assignment WHERE id=?", (assignment_id,)
).fetchone()
return int(row[0]) if row else None
def _handle_create_submission_inner(self):
content_type = self.headers.get("Content-Type") or ""
try:
length = int(self.headers.get("Content-Length", "0"))
except (TypeError, ValueError):
return json_response(self, {"error": "invalid Content-Length"}, status=400)
if length < 0 or length > cfg.submission_upload_max_bytes:
return json_response(
self,
{
"error": (
f"submission exceeds limit (max {cfg.submission_upload_max_bytes} bytes)"
)
},
status=413,
)
if "multipart/form-data" in content_type:
body = self.rfile.read(length) if length else b""
parts = parse_multipart_formdata(content_type, body)
fields: dict[str, str] = {}
file_part = None
for part in parts:
name = part.get("name")
if not name:
continue
if part.get("filename"):
file_part = part
continue
try:
fields[str(name)] = (part.get("content") or b"").decode(
"utf-8", errors="replace"
)
except Exception:
fields[str(name)] = ""
assignment_id = fields.get("assignment_id")
domain = normalize_domain(fields.get("domain"))
text_content = fields.get("content", "")
attachment_name = None
extraction_warnings: list[str] = []
if file_part and file_part.get("filename"):
safe_name = _safe_upload_filename(str(file_part.get("filename")))
if safe_name is None:
return json_response(self, {"error": "invalid filename"}, status=400)
import uuid as _uuid
dest = UPLOAD_DIR / f"submission_{_uuid.uuid4().hex}_{safe_name}"
try:
dest.resolve().relative_to(UPLOAD_DIR.resolve())
except ValueError:
return json_response(self, {"error": "invalid filename"}, status=400)
dest.write_bytes(file_part.get("content") or b"")
extracted = extract_runtime_text(dest)
extraction_warnings = list(extracted.warnings)
if not text_content.strip():
text_content = extracted.text
else:
text_content = (
f"{text_content.strip()}\n\n=== Вложение: {safe_name} ===\n\n"
f"{extracted.text}"
)
attachment_name = safe_name
if not assignment_id:
return json_response(self, {"error": "assignment_id required"}, status=400)
try:
assignment_id_int = int(assignment_id)
except (TypeError, ValueError):
return json_response(self, {"error": "invalid assignment_id"}, status=400)
if not text_content.strip():
return json_response(
self,
{"error": "Нужен либо текст ответа, либо файл с содержимым."},
status=400,
)
conn = connect_db()
try:
if not assignment_exists(conn, assignment_id_int):
return json_response(self, {"error": "assignment not found"}, status=404)
course_id = self._assignment_course_id(conn, assignment_id_int) or LEGACY_COURSE_ID
user = self._current_user(conn)
# Authenticated students must be enrolled to submit.
if course_id != LEGACY_COURSE_ID:
if not user:
return json_response(self, {"error": "authentication required"}, status=401)
if user.get("role") == "student" and not is_enrolled(
conn, course_id, user["id"]
):
return json_response(self, {"error": "not enrolled"}, status=403)
student_name, student_id = self._current_student_identity(
conn, fields.get("student_name")
)
conn.execute(
"INSERT INTO submission(assignment_id, student_name, content, created_at, "
"status, domain, attachment_name, extraction_warnings, student_id) "
"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
assignment_id_int,
student_name,
text_content.strip(),
now_iso(),
"submitted",
domain,
attachment_name,
json.dumps(extraction_warnings, ensure_ascii=False)
if extraction_warnings
else None,
student_id,
),
)
conn.commit()
finally:
conn.close()
return json_response(
self,
{
"ok": True,
"attachment_name": attachment_name,
"extraction_warnings": extraction_warnings,
"domain": domain,
},
)
# Default: JSON body
body = self.rfile.read(length).decode("utf-8") if length else "{}"
try:
data = json.loads(body) if body else {}
except json.JSONDecodeError:
return json_response(self, {"error": "invalid JSON"}, status=400)
assignment_id = data.get("assignment_id")
domain = normalize_domain(data.get("domain"))
content = str(data.get("content", ""))
if not assignment_id or not content.strip():
return json_response(self, {"error": "assignment_id and content required"}, status=400)
try:
assignment_id_int = int(assignment_id)
except (TypeError, ValueError):
return json_response(self, {"error": "invalid assignment_id"}, status=400)
conn = connect_db()
try:
if not assignment_exists(conn, assignment_id_int):
return json_response(self, {"error": "assignment not found"}, status=404)
course_id = self._assignment_course_id(conn, assignment_id_int) or LEGACY_COURSE_ID
user = self._current_user(conn)
if course_id != LEGACY_COURSE_ID:
if not user:
return json_response(self, {"error": "authentication required"}, status=401)
if user.get("role") == "student" and not is_enrolled(conn, course_id, user["id"]):
return json_response(self, {"error": "not enrolled"}, status=403)
student_name, student_id = self._current_student_identity(
conn, data.get("student_name")
)
conn.execute(
"INSERT INTO submission(assignment_id, student_name, content, created_at, "
"status, domain, student_id) VALUES(?, ?, ?, ?, ?, ?, ?)",
(
assignment_id_int,
student_name,
content.strip(),
now_iso(),
"submitted",
domain,
student_id,
),
)
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True, "domain": domain})
def _do_PATCH(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing
parsed = urlparse(self.path)
path = parsed.path
if path == "/api/auth/me":
return self._handle_update_profile()
if path.startswith("/api/assignments/"):
rest = path[len("/api/assignments/") :]
if not rest.isdigit():
return self.send_error(404)
assignment_id = int(rest)
data = self._read_json_body()
if data is None:
return
conn = connect_db()
try:
user = self._current_user(conn)
row = conn.execute(
"SELECT course_id FROM assignment WHERE id=?", (assignment_id,)
).fetchone()
if not row:
return json_response(self, {"error": "not found"}, status=404)
course_id = int(row[0])
if course_id != LEGACY_COURSE_ID:
if not user or user.get("role") not in ("teacher", "admin"):
return json_response(self, {"error": "teacher only"}, status=403)
if not self._assert_course_access(conn, user, course_id):
return
ok = update_assignment(
conn,
assignment_id,
description=data.get("description"),
rubric=data.get("rubric"),
title=data.get("title"),
)
finally:
conn.close()
if not ok:
return json_response(self, {"error": "not found"}, status=404)
return json_response(self, {"ok": True})
if path.startswith("/api/courses/"):
rest = path[len("/api/courses/") :]
if rest.isdigit():
return self._handle_patch_course(int(rest))
self.send_error(404)
def _do_DELETE(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing
parsed = urlparse(self.path)
path = parsed.path
if path.startswith("/api/submissions/"):
rest = path[len("/api/submissions/") :]
if not rest.isdigit():
return self.send_error(404)
submission_id = int(rest)
conn = connect_db()
try:
user = self._current_user(conn)
sub = conn.execute(
"SELECT s.id, s.student_id, a.course_id FROM submission s "
"JOIN assignment a ON a.id = s.assignment_id WHERE s.id=?",
(submission_id,),
).fetchone()
if not sub:
return json_response(self, {"error": "not found"}, status=404)
course_id = int(sub[2])
# Teacher or owning student may delete their submission.
if course_id != LEGACY_COURSE_ID:
if not user:
return json_response(self, {"error": "authentication required"}, status=401)
role = user.get("role")
if role == "student":
if sub[1] != user["id"]:
return json_response(self, {"error": "forbidden"}, status=403)
else:
if not self._assert_course_access(conn, user, course_id):
return
ok = delete_submission(conn, submission_id)
finally:
conn.close()
if not ok:
return json_response(self, {"error": "not found"}, status=404)
return json_response(self, {"ok": True})
if path.startswith("/api/courses/"):
rest = path[len("/api/courses/") :]
if "/" not in rest and rest.isdigit():
return self._handle_delete_course(int(rest))
if rest.endswith("/leave") and rest[: -len("/leave")].isdigit():
return self._handle_leave_course(int(rest[: -len("/leave")]))
if "/enrollments/" in rest:
course_str, _, student_str = rest.partition("/enrollments/")
if course_str.isdigit() and student_str.isdigit():
return self._handle_unenroll(int(course_str), int(student_str))
self.send_error(404)
# --- Profile / course management ----------------------------------------
def _handle_update_profile(self):
user = self._require_user()
if not user:
return
data = self._read_json_body()
if data is None:
return
from prepodov_ai.webauth.passwords import (
PasswordError,
hash_password,
)
from prepodov_ai.webauth.passwords import (
verify_password as _verify,
)
from prepodov_ai.webauth.repository import _normalize_display_name
conn = connect_db()
try:
updates: list[str] = []
params: list = []
if "display_name" in data:
try:
display = _normalize_display_name(data.get("display_name"))
except Exception as exc:
return json_response(self, {"error": str(exc)}, status=400)
updates.append("display_name=?")
params.append(display)
new_password = data.get("new_password")
if new_password:
current = data.get("current_password", "")
row = conn.execute(
"SELECT password_hash FROM user WHERE id=?", (user["id"],)
).fetchone()
if not row or not _verify(current, row[0]):
return json_response(self, {"error": "current password incorrect"}, status=400)
try:
new_hash = hash_password(new_password)
except PasswordError as exc:
return json_response(self, {"error": str(exc)}, status=400)
updates.append("password_hash=?")
params.append(new_hash)
# Invalidate all other sessions for this user after password change.
current_token = self._current_session_token()
conn.execute(
"DELETE FROM session WHERE user_id=? AND token<>?",
(user["id"], current_token or ""),
)
if not updates:
return json_response(self, {"user": user})
params.append(user["id"])
conn.execute(f"UPDATE user SET {', '.join(updates)} WHERE id=?", tuple(params))
conn.commit()
refreshed = conn.execute(
"SELECT id, email, role, display_name FROM user WHERE id=?",
(user["id"],),
).fetchone()
finally:
conn.close()
if not refreshed:
return json_response(self, {"error": "user missing"}, status=500)
return json_response(
self,
{
"user": {
"id": int(refreshed[0]),
"email": refreshed[1],
"role": refreshed[2],
"display_name": refreshed[3],
}
},
)
def _handle_delete_course(self, course_id: int):
user = self._require_user(role="teacher")
if not user:
return
conn = connect_db()
try:
if not self._assert_course_owner(conn, user, course_id):
return
# Rely on ON DELETE CASCADE for enrollments; assignments +
# submissions are tied by course_id but lack a FK — delete
# them explicitly in the correct order.
conn.execute(
"DELETE FROM submission WHERE assignment_id IN "
"(SELECT id FROM assignment WHERE course_id=?)",
(course_id,),
)
conn.execute("DELETE FROM assignment WHERE course_id=?", (course_id,))
conn.execute("DELETE FROM course WHERE id=?", (course_id,))
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True})
def _handle_patch_course(self, course_id: int):
"""Rename a course or change its subject_area / grade_level."""
user = self._require_user(role="teacher")
if not user:
return
data = self._read_json_body()
if data is None:
return
conn = connect_db()
try:
if not self._assert_course_owner(conn, user, course_id):
return
updates: list[str] = []
params: list = []
if "title" in data:
clean = str(data.get("title") or "").strip()
if not clean:
return json_response(self, {"error": "title cannot be empty"}, status=400)
updates.append("title=?")
params.append(clean[:80])
if "subject_area" in data:
updates.append("subject_area=?")
params.append(normalize_domain(data.get("subject_area")))
if "grade_level" in data:
grade = str(data.get("grade_level") or "").strip()[:40] or None
updates.append("grade_level=?")
params.append(grade)
if not updates:
return json_response(self, {"ok": True, "changed": False})
updates.append("updated_at=?")
params.append(now_iso())
params.append(course_id)
conn.execute(f"UPDATE course SET {', '.join(updates)} WHERE id=?", tuple(params))
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True, "changed": True})
def _assert_course_owner(self, conn, user, course_id: int) -> bool:
"""Shared guard: teacher must own the course (or be admin)."""
owner = get_course_owner(conn, course_id)
if owner is None:
json_response(self, {"error": "course not found"}, status=404)
return False
if owner != user["id"] and user.get("role") != "admin":
json_response(self, {"error": "not your course"}, status=403)
return False
return True
def _handle_leave_course(self, course_id: int):
"""Student leaves a course — self-service unenroll.
Safer to ship as a dedicated endpoint than to reuse the teacher-owned
``DELETE /api/courses/{id}/enrollments/{student_id}`` because the
teacher endpoint is authorized through course ownership, not the
student's identity. Keeps the authorization clauses distinct.
"""
user = self._require_user(role="student")
if not user:
return
conn = connect_db()
try:
if not is_enrolled(conn, course_id, user["id"]):
return json_response(self, {"error": "not enrolled"}, status=404)
conn.execute(
"DELETE FROM enrollment WHERE course_id=? AND student_id=?",
(course_id, user["id"]),
)
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True})
def _handle_unenroll(self, course_id: int, student_id: int):
user = self._require_user(role="teacher")
if not user:
return
conn = connect_db()
try:
if not self._assert_course_owner(conn, user, course_id):
return
conn.execute(
"DELETE FROM enrollment WHERE course_id=? AND student_id=?",
(course_id, student_id),
)
conn.commit()
finally:
conn.close()
return json_response(self, {"ok": True})
def log_message(self, fmt, *args):
# Request logs go through the ``http`` logger so the request id, IP and
# structured-vs-plain format all come from one place.
rid = REQUEST_ID.get()
logging.getLogger("prepodov.http").info(
fmt % args,
extra={"request_id": rid, "client_ip": self._client_ip()},
)
# ---------------------------------------------------------------------------
# Request IDs + structured logging
# ---------------------------------------------------------------------------
REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
class _RequestIdFilter(logging.Filter):
"""Inject the current request ID into every log record."""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "request_id"):
record.request_id = REQUEST_ID.get()
if not hasattr(record, "client_ip"):
record.client_ip = "-"
return True
class _JsonFormatter(logging.Formatter):
"""One-line JSON per log record — cheap structured logs, no extra deps."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"name": record.name,
"request_id": getattr(record, "request_id", "-"),
"client_ip": getattr(record, "client_ip", "-"),
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def _configure_logging() -> None:
"""Wire up the root logger exactly once.
Plain format for local dev (easy to read); JSON structured format when
``PREPODOV_STRUCTURED_LOGS=true``, which cloud log aggregators parse natively.
"""
root = logging.getLogger()
# Replace any handlers an import may have registered, idempotently.
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stderr)
handler.addFilter(_RequestIdFilter())
if cfg.structured_logs:
handler.setFormatter(_JsonFormatter())
else:
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(client_ip)s req=%(request_id)s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(handler)
try:
root.setLevel(getattr(logging, str(cfg.log_level).upper(), logging.INFO))
except Exception:
root.setLevel(logging.INFO)
# ---------------------------------------------------------------------------
# Graceful shutdown
# ---------------------------------------------------------------------------
def _install_shutdown_handlers(server: ThreadingHTTPServer, stop_event) -> None:
"""Install SIGINT/SIGTERM handlers that stop the server cleanly.
Cross-platform: SIGTERM doesn't exist on older Windows Python builds, so
we ignore AttributeError on those signals. ``server.shutdown()`` blocks
until ``serve_forever`` exits, which deadlocks if called from the main
thread inside the signal handler — spin it off a daemon worker.
"""
import signal as _signal
def _handler(signum, _frame):
name = {_signal.SIGINT: "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(
signum, str(signum)
)
log.info("shutdown signal received: %s", name)
stop_event.set()
threading.Thread(target=server.shutdown, daemon=True, name="server-shutdown").start()
for sig_name in ("SIGINT", "SIGTERM", "SIGBREAK"):
sig = getattr(_signal, sig_name, None)
if sig is None:
continue
try:
_signal.signal(sig, _handler)
except (ValueError, OSError, AttributeError):
# Some environments (Windows Python without console, certain
# threads) can't register signal handlers — best-effort only.
pass
log = logging.getLogger("prepodov")
def main():
"""Process entry point: configure logging, init DB, start threaded server.
Startup sequence:
1. Configure logging (plain or JSON; honours PREPODOV_LOG_LEVEL).
2. Ensure data/ and data/uploads/ exist (idempotent).
3. Run schema bootstrap + PRAGMAs on data/app.db.
4. Log effective config (tesseract path, data dir, LLM backend).
5. Bind host:port and serve until SIGINT/SIGTERM arrives.
All runtime parameters come from ``cfg`` (see runtime_config.py). To
override without touching code: edit ``.env`` or export ``PREPODOV_*``
environment variables before running.
"""
_configure_logging()
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
init_db()
log.info("startup: tesseract=%s", TESSERACT_CMD or "NOT FOUND — OCR disabled")
log.info("startup: data_dir=%s db=%s", DATA_DIR, DB_PATH)
host = cfg.host
port = cfg.port
server = ThreadingHTTPServer((host, port), Handler)
log.info("startup: listening on http://%s:%s (backend: %s)", host, port, LLM_BACKEND)
stop_event = threading.Event()
_install_shutdown_handlers(server, stop_event)
try:
server.serve_forever()
except KeyboardInterrupt:
stop_event.set()
finally:
try:
server.server_close()
except Exception:
pass
log.info("shutdown: server closed")
if __name__ == "__main__":
main()