Spaces:
Running
Running
| #!/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"}) | |
| 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() | |