diff --git a/.env.example b/.env.example index 38080cd..365d2c8 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,17 @@ # Environment variables for Lab Grader Web -# Copy this file to .env on your server and fill in the actual values +# Copy this file to .env and fill in the actual values: +# copy .env.example .env (Windows) +# cp .env.example .env (Linux/macOS) # # IMPORTANT: Never commit .env to git! # GitHub API token for accessing repositories # Generate at: https://github.com/settings/tokens # Required scopes: repo, read:org -GITHUB_TOKEN=ghp_your_github_token_here +GITHUB_TOKEN=ghp_your_token_here # Admin credentials for the web interface +# Login page: http://localhost:8080/admin ADMIN_LOGIN=your_admin_username ADMIN_PASSWORD=your_secure_password_here @@ -16,8 +19,21 @@ ADMIN_PASSWORD=your_secure_password_here # Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=your_random_secret_key_here -# Google Sheets credentials file path (inside container) -CREDENTIALS_FILE=/app/google-credentials/credentials.json +# Google Sheets credentials file path +# Local: ./credentials.json +# Docker: /app/google-credentials/credentials.json +CREDENTIALS_FILE=credentials.json # Optional: Branch to deploy (if using variable-based docker-compose.yaml) # BRANCH=main + +# --- Plagiarism detection (optional) --- +# Directory for cached student sources + plagiarism.db +# Created automatically on first successful grade with moss:/plagiarism: config +# PLAGIARISM_CACHE_DIR=plagiarism_cache + +# Override SQLite DB path (default: {PLAGIARISM_CACHE_DIR}/plagiarism.db) +# PLAGIARISM_DB_PATH=plagiarism_cache/plagiarism.db + +# Pilot mode: run checks and store matches, but do NOT write Sheets notes/comments +# PLAGIARISM_SHADOW_MODE=true diff --git a/.gitignore b/.gitignore index 06ff23a..2cf52ae 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,17 @@ ENV/ env.bak/ venv.bak/ +# Plagiarism detection cache and DB +plagiarism_cache/ +plagiarism.db + +# Interactive CLI last-used course/group +.labgrader_cli.json + +# Local JPlag jar (download separately; see scripts/compare_engines_jplag_compare50.py) +tools/jplag.jar +tools/*.jar + # Docker deployment files (only for server, not in git) docker-compose.yaml docker-compose.yml diff --git a/CLAUDE.md b/CLAUDE.md index ea6b75a..a305922 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,9 @@ docker-compose up cd frontend/courses-front && npm install --legacy-peer-deps && npm run dev pip install -r requirements.txt && uvicorn main:app --reload --port 8000 +# Interactive CLI (grade / plagiarism / matches) +python scripts/menu.py + # Run tests pytest tests/ -v pytest tests/ --cov=. --cov-report=term-missing diff --git a/docker-compose.example.yaml b/docker-compose.example.yaml index b3a75b2..4f46188 100644 --- a/docker-compose.example.yaml +++ b/docker-compose.example.yaml @@ -25,6 +25,7 @@ services: - ./google-credentials:/app/google-credentials/ - ./courses:/app/courses # Mount courses directory for easy editing - ./logs:/app/logs # Persistent logs (survive container restarts) + - ./plagiarism_cache:/app/plagiarism_cache # Source cache + plagiarism.db environment: # Credentials and secrets CREDENTIALS_FILE: /app/google-credentials/credentials.json @@ -35,6 +36,9 @@ services: # Logging configuration LOG_DIR: /app/logs # Directory for persistent log files LOG_LEVEL: INFO # Options: DEBUG, INFO, WARNING, ERROR (use DEBUG for detailed troubleshooting) + # Plagiarism detection + PLAGIARISM_CACHE_DIR: /app/plagiarism_cache + # PLAGIARISM_SHADOW_MODE: "true" # Pilot: store matches, skip teacher notifications labels: caddy: labgrader.markpolyak.ru # API endpoints diff --git a/docs/CHANGES_FOR_TEACHER.md b/docs/CHANGES_FOR_TEACHER.md new file mode 100644 index 0000000..f339a5a --- /dev/null +++ b/docs/CHANGES_FOR_TEACHER.md @@ -0,0 +1,206 @@ +# Что добавлено в lab_grader_web (антиплагиат) — разбор для преподавателя + +Документ описывает изменения относительно исходного репозитория (где секция `moss:` в YAML была, но **в коде не работала**). +Ниже: **что добавлено / где лежит / зачем / как показать руками**. + +--- + +## Кратко: что изменилось в системе + +| Было | Стало | +|------|--------| +| Плагиат вручную через MOSS в конце семестра | Автопроверка **в момент сдачи** (`v` в таблицу) | +| Конфиг `moss:` «мёртвый» | Работает как `plagiarism:` / алиас `moss:` | +| Нет локального корпуса | Кэш `plagiarism_cache/` + БД `plagiarism.db` | +| Нет UI по совпадениям | Админка: список пар + кнопка «отметить» | +| — | Note (заметка) на ячейке оценки в Google Sheets | + +**Оценка студенту алгоритмом не меняется** — только флаг преподавателю. + +--- + +## 1. Новые модули backend (`grading/`) + +### 1.1. `grading/plagiarism_cache.py` +- **Зачем:** скачать из GitHub только нужные файлы лабы и хранить локально. +- **Что делает:** кэш `plagiarism_cache/{курс}/{лаба}/{org}/{repo}/`, конвертация `.ipynb` → `.py`. + +**Как показать:** +1. Открой в проводнике / IDE папку `plagiarism_cache/` (после хотя бы одной проверки). +2. Зайди в `os-2025-spring` → номер лабы → `suai-os-2025` → репозиторий студента → файлы (`lab2.cpp` и т.п.). + +### 1.2. `grading/plagiarism.py` +- **Зачем:** вызвать compare50 и получить пары с similarity 0…1. +- **Что делает:** инкрементальное сравнение «новая работа vs корпус», `basefiles`, порог из YAML. + +**Как показать:** +1. Открой файл в IDE, найди `compare_submission_against_cache` / `run_compare50`. +2. Либо в консоли: `python scripts/menu.py` → пункт **5** (локальный demo) — увидишь совпадения без Sheets. + +### 1.3. `grading/plagiarism_store.py` +- **Зачем:** сохранить пары в SQLite и флаг `reviewed_by_teacher`. +- **Файл БД:** рядом с кэшем — `plagiarism.db` (или путь из `PLAGIARISM_DB_PATH`). + +**Как показать:** +1. `python scripts/menu.py` → **3) Показать совпадения из SQLite**. +2. Выбери курс и лабу → в консоли список пар и `[✓]` / пусто у review. + +### 1.4. `grading/plagiarism_check.py` +- **Зачем:** оркестрация всего фона после `v`. +- **Цепочка:** конфиг → кэш → compare50 → БД → (если не SHADOW) note в Sheets. + +**Как показать:** +1. В `.env` поставь `PLAGIARISM_SHADOW_MODE=false`. +2. Очисти/поставь `?` в ячейке студента на листе группы. +3. `python scripts/menu.py` → **1) Оценить лабы студента** → курс / группа / github / лабы. +4. В логе `logs/labgrader.log` ищи `Plagiarism check for ... above threshold`. +5. В Google Sheets наведи на ячейку оценки → должна быть **заметка (note)**. + +### 1.5. `grading/sheets_comments.py` +- **Зачем:** текст и запись **note** на ячейку (не discussion comment — API Google так не умеет для Sheets). +- **Важно:** оценку не переписывает. + +**Как показать:** Google Таблица курса → лист группы (напр. `4333K`) → ячейка с `v` у студента, у которого было совпадение ≥ порога → треугольник / note при наведении. + +### 1.6. Дополнение `grading/github_client.py` +- **Зачем:** метод `get_file_content` для скачивания одного файла через GitHub API (нужен кэшу). + +**Как показать:** открой `grading/github_client.py`, найди `get_file_content`. + +--- + +## 2. Изменения в `main.py` (точка встраивания) + +| Что добавлено | Зачем | +|---------------|--------| +| После успешного `v...` — `background_tasks.add_task(run_plagiarism_check, ...)` | Проверка не блокирует ответ студенту | +| `GET /courses/{course_id}/labs/{lab_id}/plagiarism` | Список пар для админки | +| `POST /courses/{course_id}/labs/{lab_id}/plagiarism/review` | Флаг «я проверил» → `reviewed_by_teacher` в БД | +| В `GET /courses/{id}` поле `has_plagiarism` у лаб | Админка знает, где настроен антиплагиат | + +**Как показать API (Swagger):** +1. Запусти backend: `python scripts/menu.py` → **8** (или `python -m uvicorn main:app --reload --port 8000`). +2. Открой в браузере: http://127.0.0.1:8000/docs +3. Найди эндпоинты `.../plagiarism` и `.../plagiarism/review`. + +--- + +## 3. Frontend — админ-UI + +### Новые файлы +- `frontend/courses-front/src/components/plagiarism-matches/index.jsx` — таблица пар +- `frontend/courses-front/src/components/plagiarism-matches/adminLabList.jsx` — список лаб курса +- `frontend/courses-front/src/components/plagiarism-matches/styled.js` +- правки `App.jsx` (маршруты) +- строки в `locales/ru|en|zh/translation.json` + +### Маршруты +1. `/admin` — логин +2. `/admin/courses` — выбор курса +3. `/admin/courses/:courseId/labs` — лабы +4. `/admin/courses/:courseId/labs/:labId/plagiarism` — **совпадения** + +**Как показать преподавателю (пошагово):** + +1. В консоли: `python scripts/menu.py` → **8) Запустить админ-панель**. +2. Дождись вывода URL, например: `http://127.0.0.1:5173/admin` (порт может быть 5173–518x). +3. Открой этот URL (меню само предложит браузер). +4. Введи `ADMIN_LOGIN` / `ADMIN_PASSWORD` из `.env` → **Login**. +5. Нажми курс (например, с `os-2025-spring` / ОС). +6. Нажми лабораторную, где написано «проверка настроена». +7. Увидишь таблицу: студенты, %, reviewed. +8. Нажми **«Отметить»** у пары → в БД `reviewed_by_teacher=1` (в UI станет «да»). + Это **только чекбокс в БД**, в Google Sheets и студенту ничего не уходит. + +--- + +## 4. Скрипты (`scripts/`) + +| Файл | Зачем | Как запустить / показать | +|------|--------|---------------------------| +| `menu.py` | Единое консольное меню | `python scripts/menu.py` | +| `grade_student_labs.py` | Прогнать сдачу + фон плагиата | Меню → **1** | +| `plagiarism_batch_course.py` | Batch по курсу без Sheets | Меню → **2** | +| `plagiarism_local_demo.py` | Демо без Google | Меню → **5** | +| `compare_engines_jplag_compare50.py` | Сравнение с JPlag | Меню → **4** (нужен `tools/jplag.jar`) | +| `experiment_sheet_cell_comment.py` | Исследование якоря Drive comments | опционально, для защиты «почему notes» | + +--- + +## 5. Конфиг, Docker, зависимости, доки + +| Файл | Что сделано | Зачем | +|------|-------------|--------| +| `requirements.txt` | `compare50>=1.2.0` | Движок плагиата | +| `docker-compose.example.yaml` | volume `plagiarism_cache`, env `PLAGIARISM_*` | Данные не пропадают в Docker | +| `.env.example` | `PLAGIARISM_SHADOW_MODE`, пути кэша/БД | Пилот без notes / настройка путей | +| `.gitignore` | `plagiarism_cache/`, `plagiarism.db`, `.labgrader_cli.json`, `tools/*.jar` | Не коммитить кэш и секреты состояния | +| `docs/COURSE_CONFIG.md` | Раздел plagiarism / notes | Как писать YAML | +| `docs/PROJECT_DESCRIPTION.md` | Описание фичи | Обзор проекта | +| `docs/PLAGIARISM_DETECTION_PLAN.md` | План + решения (notes, compare50 vs JPlag) | Исходная постановка и итоги исследования | +| `docs/PRACTICE_REPORT_PLAGIARISM.md` | Текст отчёта по практике | Для сдачи отчёта | +| `CLAUDE.md` | Команда `python scripts/menu.py` | Быстрый вход для разработки | +| YAML курсов | уже был `moss:` — теперь **читается кодом** как алиас | Не ломает старые конфиги | + +**Как показать конфиг:** +Открой `courses/operating-systems-2025.yaml` (или актуальный курс) → секция `moss:` / `plagiarism:` у лабы (language, additional, threshold…). + +**Как показать shadow-режим:** +`.env` → `PLAGIARISM_SHADOW_MODE=true` → оценка пройдёт, БД заполнится, **note в таблице не появится**. Меню → **7** — подсказка. + +--- + +## 6. Тесты (`tests/`) + +| Файл | Что покрывает | +|------|----------------| +| `tests/test_plagiarism_cache.py` | кэш, ipynb | +| `tests/test_plagiarism.py` | compare50 на фикстурах | +| `tests/test_plagiarism_store.py` | SQLite + review | +| `tests/test_sheets_comments.py` | формат текста note / A1 | + +**Как показать:** +```powershell +cd C:\Users\sonchikeslicho\Desktop\lab_grader_web-main +python -m pytest tests/test_plagiarism_cache.py tests/test_plagiarism.py tests/test_plagiarism_store.py tests/test_sheets_comments.py -v +``` + +--- + +## 7. Рекомендуемый сценарий демонстрации (15–20 минут) + +1. **Консоль:** `python scripts/menu.py` → **8** → скопировать URL админки из вывода. +2. **Браузер:** открыть `/admin` → логин → курс → лаба → список пар → «Отметить». +3. **Swagger:** http://127.0.0.1:8000/docs → показать endpoints plagiarism. +4. **Проводник:** папка `plagiarism_cache/` + файл `plagiarism.db`. +5. **Google Sheets:** note на ячейке (если уже был прогон с `SHADOW=false`). +6. **IDE:** коротко пройти файлы `grading/plagiarism_*.py` и место в `main.py` с `BackgroundTasks`. +7. **Консоль:** меню **3** — те же пары из БД; меню **4** (по желанию) — цифры compare50 vs JPlag из отчёта. + +--- + +## 8. Чего в изменениях нет (чтобы не ждали) + +- Автозамены `v` → `x` при плагиате +- Детекции ИИ-кода +- Настоящих discussion-комментариев Google к ячейке (технически недоступны через API) +- Движка JPlag в Docker-проде (только исследование; в проде — compare50) + +--- + +## 9. Шпаргалка команд + +```powershell +# Меню всего +python scripts/menu.py + +# Админка = пункт 8 в меню +# URL смотри в выводе, обычно: +# http://127.0.0.1:5173/admin (порт может отличаться) +# http://127.0.0.1:8000/docs + +# Тесты антиплагиата +python -m pytest tests/test_plagiarism*.py tests/test_sheets_comments.py -v +``` + +Логин админки: переменные `ADMIN_LOGIN` / `ADMIN_PASSWORD` в `.env`. diff --git a/docs/COURSE_CONFIG.md b/docs/COURSE_CONFIG.md index b7e6f26..4dcbe02 100644 --- a/docs/COURSE_CONFIG.md +++ b/docs/COURSE_CONFIG.md @@ -342,61 +342,113 @@ forbidden-modifications: --- -## MOSS (Проверка плагиата) +## MOSS / Plagiarism (Проверка плагиата) -### `moss.language` (обязательно) +Секция `plagiarism:` задаёт автоматическую проверку на плагиат при успешной сдаче (`v...`). +Устаревший алиас `moss:` поддерживается для обратной совместимости (те же поля). + +Движок по умолчанию — локальный **compare50** (без внешней зависимости от Stanford MOSS). +Проверка запускается в фоне после записи оценки и не блокирует ответ студенту. + +### `plagiarism.engine` **Тип:** `string` -**Описание:** Язык программирования для проверки MOSS -**Возможные значения:** `c`, `cc`, `python`, `java`, и др. +**По умолчанию:** `compare50` +**Описание:** Движок сравнения. Сейчас реализован `compare50`; `moss` — алиас к нему. +**Пример:** +```yaml +plagiarism: + engine: compare50 +``` + +### `plagiarism.language` +**Тип:** `string` +**Описание:** Язык (информативно / для legacy MOSS). compare50 определяет язык по расширению файла. +**Пример:** +```yaml +plagiarism: + language: python +``` + +### `plagiarism.threshold` +**Тип:** `float` (0..1) +**По умолчанию:** `0.6` +**Описание:** Порог сходства для флага преподавателю. Оценка в таблице не меняется. **Пример:** ```yaml -moss: - language: cc +plagiarism: + threshold: 0.7 ``` -### `moss.max-matches` +### `plagiarism.max-matches` / `moss.max-matches` **Тип:** `integer` -**По умолчанию:** `250` -**Описание:** Максимальное количество совпадений для отображения +**По умолчанию:** `50` +**Описание:** Максимальное число пар в отчёте движка **Пример:** ```yaml -moss: - max-matches: 1000 +plagiarism: + max-matches: 100 ``` -### `moss.local-path` +### `plagiarism.local-path` / `moss.local-path` **Тип:** `string` -**Описание:** Путь к директории с файлами в репозитории (если файлы не в корне) +**Описание:** Поддиректория в репозитории, если файлы не в корне (при скачивании в кэш) **Пример:** ```yaml -moss: +plagiarism: local-path: lab3 ``` -### `moss.additional` +### `plagiarism.additional` / `moss.additional` **Тип:** `list[string]` -**Описание:** Список дополнительных GitHub организаций для проверки (старые годы обучения) +**Описание:** Дополнительные GitHub-организации для сравнения (прошлые годы). Репозитории +должны быть предварительно закэшированы в `plagiarism_cache/`. **Пример:** ```yaml -moss: +plagiarism: additional: - suai-os-2023 - suai-os-2024 ``` -### `moss.basefiles` +### `plagiarism.basefiles` / `moss.basefiles` **Тип:** `list[dict]` -**Описание:** Базовые файлы (шаблоны), которые исключаются из проверки на плагиат +**Описание:** Базовые/шаблонные файлы, исключаемые из сравнения **Пример:** ```yaml -moss: +plagiarism: basefiles: - repo: teacher/template-repo filename: lab3.cpp - - repo: teacher/template-repo - filename: examples/helper.cpp ``` +### Переменные окружения + +| Переменная | Описание | +|---|---| +| `PLAGIARISM_CACHE_DIR` | Корень кэша исходников (по умолчанию `plagiarism_cache`) | +| `PLAGIARISM_DB_PATH` | Путь к SQLite БД (по умолчанию `{cache}/plagiarism.db`) | +| `PLAGIARISM_SHADOW_MODE` | `true` — считать совпадения, но не уведомлять преподавателя (пилот) | + +### API + +`GET /courses/{course_id}/labs/{lab_id}/plagiarism` — список пар выше порога (требует admin-сессию). + +`POST /courses/{course_id}/labs/{lab_id}/plagiarism/review` — отметить пару как рассмотренную: +```json +{"student_a": "alice", "student_b": "bob", "reviewed": true} +``` + +Админ-UI: `/admin/courses` → Выбрать курс → лабораторная → список совпадений. + +### Уведомления преподавателя + +При совпадении выше порога (и если не включён `PLAGIARISM_SHADOW_MODE`): +**заметка (note)** на ячейке оценки — видна при наведении в Google Sheets +(в том числе роли Reader). Сама оценка (`v`, `v@…`) не меняется. + +> Cell-anchored discussion comments через Drive API v3 создать нельзя +> (см. `docs/PLAGIARISM_DETECTION_PLAN.md` §6) — поэтому используется note. + --- ## Требования к отчёту diff --git a/docs/PLAGIARISM_DETECTION_PLAN.md b/docs/PLAGIARISM_DETECTION_PLAN.md index e889c79..d95f945 100644 --- a/docs/PLAGIARISM_DETECTION_PLAN.md +++ b/docs/PLAGIARISM_DETECTION_PLAN.md @@ -87,18 +87,11 @@ Dolos держим как третий вариант: самый красивы ### 4.5 Флаги для преподавателя -Основной канал — **комментарий (comment) к ячейке** лабораторной работы в Google Таблице, добавляется автоматически только если similarity выше порога. Сам грейд (`v`, `v@10.5` и т.д.) не изменяется. +Основной канал — **заметка (note) к ячейке** лабораторной работы в Google Таблице, добавляется автоматически только если similarity выше порога. Сам грейд (`v`, `v@10.5` и т.д.) не изменяется. -Комментарии (в отличие от примечаний/notes) поддерживают тред-структуру: бот оставляет комментарий с информацией о проценте совпадения, преподаватель отвечает в этом же треде, подтверждая или опровергая плагиат. +Изначально планировались Drive discussion comments (треды), но публичные API не умеют создавать cell-anchored comments в Sheets (см. §6). Решение: только note (видно в т.ч. Reader’ам) + review в админ-панели. -Техническая реализация — Google Drive API v3, ресурс `comments` (`comments.create`, `comments.list`, `replies.create`, `replies.list`), не `gspread`-примечания (`insert_note`/`update_note` работают с другим механизмом — notes, не comments). Существующий сервис-аккаунт уже имеет scope `https://www.googleapis.com/auth/drive` (main.py), дополнительных разрешений не требуется. - -Технические риски, требующие проверки на этапе предварительного исследования (раздел 5, фаза 0): -- формат поля `anchor` для привязки комментария к конкретной ячейке Google Sheets официально не задокументирован Google (документирован только для Google Docs); нужно проверить на практике, что комментарий действительно прикрепляется к нужной ячейке лабораторной работы, а не к файлу в целом; -- видимость комментариев для роли Viewer (студенты с доступом только на чтение) — нужно подтвердить эмпирически на реальной таблице курса. -- определение факта ответа/разрешения треда преподавателем (`replies.list` / статус `resolved`) для последующей синхронизации с полем `reviewed_by_teacher` (раздел 4.4). - -Дополнительный канал — отдельная страница в админ-панели (`frontend/courses-front/src/components/`), обращающаяся к новому эндпоинту, например `GET /courses/{course_id}/labs/{lab_id}/plagiarism`, со списком пар выше порога и деталями совпадения. +Дополнительный канал — страница в админ-панели со списком пар выше порога (`GET /courses/{course_id}/labs/{lab_id}/plagiarism`). ### 4.6 Конфигурация курса @@ -136,8 +129,31 @@ plagiarism: ## 6. Открытые технические риски (проверить на этапе предварительного исследования) - Формат поля `anchor` для привязки комментария Google Drive API v3 к конкретной ячейке Google Sheets — официально не задокументирован для Sheets (задокументирован только для Docs). -- Видимость комментариев для роли Viewer в реальной таблице курса. -- Значение по умолчанию для `threshold`, если не задан в конфиге лабы. + **Статус реализации (2026-07):** публичные API **не умеют** создавать cell-anchored discussion comments в Sheets. + - Эмпирика на таблице курса (`scripts/experiment_sheet_cell_comment.py`): `comments.create` принимает любой JSON в `anchor` и сохраняет его as-is, но UI **не** привязывает тред к ячейке. Экспорт XLSX: у ячейки T50 (только Drive-комментарий с `workbook-range`) комментария нет; у F21/G21 в XLSX есть «комментарии» — это экспорт **notes**, не Drive threads. + - Настоящие UI-комментарии имеют proprietary anchor вида `{"type":"workbook-range","uid":0,"range":""}`; способ сгенерировать этот `range` id через публичный API неизвестен (Issue Tracker: 36756650, Apps Script / Sheets API — только notes). + - XLSX round-trip (openpyxl Comment → upload convert) на service account упёрся в `storageQuotaExceeded`; по публичным данным Excel comments при конвертации становятся notes, не discussion threads. + - **Решение (2026-07-23):** в коде только **gspread note** на ячейке оценки. + Drive comments отключены (cell-anchor недоступен; file-level тред без привязки к ячейке путает UX). +- Видимость **notes** для роли Viewer в реальной таблице курса: **подтверждено** (Reader видит notes). +- Значение по умолчанию для `threshold`, если не задан в конфиге лабы. **Решено:** `DEFAULT_PLAGIARISM_THRESHOLD = 0.6`. + +### Выбор движка (фаза 0) — compare50 vs JPlag + +Прогон на кэше `os-2025-spring` (`scripts/compare_engines_jplag_compare50.py`, JPlag 6.2.0 `-l cpp --normalize`): + +| Лаба | Сабмиты | Top-25 overlap | ≥0.6 compare50 | ≥0.6 JPlag | Пересечение ≥0.6 | +|------|---------|----------------|----------------|------------|------------------| +| ЛР2 | ~98 | 7 / 25 | 49 | 295 | 39 | +| ЛР3 | ~185 | 10 / 25 | 3 | 773 | 3 | + +Выводы: +- **Сильные клоны** оба движка находят в топе (JustTedd↔remboyd, Foureh↔nidzhat666, JMListener↔markpolyak и т.п.). +- **JPlag** даёт абсолютную AVG-схожесть и Report Viewer, но без `-bc` (base code) порог 0.6 на OS-лабах даёт сотни срабатываний (общий каркас задания). +- **compare50** (наш wrapper) нормализует score относительно max в прогоне → на *полном* корпусе фиксированный порог 0.6 может занижать near-clone, если есть ещё более сильный топ (ЛР3: MikeMalenkov2005↔markpolyak ≈0.59 при JPlag AVG=1.0). В **инкрементальном** режиме (новая сдача vs archive) это менее критично. +- **Эксплуатация:** compare50 = `pip install`, без JRE; JPlag = JDK 21+ и ~80 MB jar в образе. + +**Решение:** оставить **compare50** как основной движок. JPlag в Docker сейчас не внедряем; при необходимости — отдельная итерация с JRE + обязательным `basefiles`/`-bc`. ## 7. Не входит в объём данной фичи diff --git a/docs/PRACTICE_REPORT_PLAGIARISM.md b/docs/PRACTICE_REPORT_PLAGIARISM.md new file mode 100644 index 0000000..5831af9 --- /dev/null +++ b/docs/PRACTICE_REPORT_PLAGIARISM.md @@ -0,0 +1,319 @@ +# Отчёт по индивидуальному заданию практики +## Автоматическая проверка студенческих работ на плагиат в системе lab_grader_web + +--- + +## 1. Описание предметной области + +Система **lab_grader_web** предназначена для автоматической проверки лабораторных работ студентов технических специальностей. Типовой сценарий: + +1. студент размещает решение в репозитории GitHub организации курса; +2. GitHub Actions (CI) прогоняет автотесты и статический анализ; +3. студент инициирует сдачу через веб-интерфейс; +4. сервис проверяет наличие репозитория, успешность CI, соответствие варианта задания и записывает результат в **Google Таблицу** курса (`v`, `x`, `v-{n}`, `v@{score}` и т.п.). + +Отдельной, но критически важной задачей для преподавателя является **контроль академической честности** — выявление копирования кода между студентами (в том числе между семестрами и потоками). + +До начала работы по индивидуальному заданию проверка на плагиат в экосистеме lab-grader выполнялась **вручную** с помощью сервиса **MOSS** (Measure Of Software Similarity, Stanford University): + +- конфигурация `moss:` уже присутствовала в YAML-описаниях курсов, но в актуальном коде веб-сервиса **не использовалась**; +- MOSS рассчитан на **пакетную** (batch) отправку всего корпуса работ на внешние серверы; +- на практике проверка проводилась в конце семестра, когда все работы уже сданы. + +Такой режим не позволяет оперативно реагировать на подозрительные совпадения в момент приёма работы и создаёт зависимость от внешнего сервиса (очереди, квоты, необходимость сети до Stanford). + +Предметная область индивидуального задания — **встраивание локальной инкрементальной проверки на плагиат** в существующий контур автопроверки: при успешной простановке оценки сравнивать новую работу с накопленным корпусом и уведомлять преподавателя без изменения самой оценки. + +--- + +## 2. Перечень задач, решаемых в рамках практики + +В рамках практики решались следующие задачи: + +1. **Анализ** текущего процесса проверки на плагиат (MOSS) и ограничений batch-модели. +2. **Сравнение** локальных инструментов детекции сходства исходного кода: compare50, JPlag (и обзор Dolos) с учётом стека Docker (`python:3.12-slim`). +3. **Проектирование** архитектуры: кэш исходников, движок сравнения, хранение результатов, уведомления, админ-интерфейс. +4. **Реализация кэша** файлов студенческих репозиториев (только файлы из конфига лабы; конвертация `.ipynb` → `.py`). +5. **Реализация обёртки** движка compare50 и инкрементального сравнения «новая сдача vs корпус». +6. **Интеграция** в эндпоинт `grade_lab` через `BackgroundTasks` (без блокировки ответа студенту). +7. **Хранение** пар совпадений в SQLite с флагом ручного review преподавателем. +8. **Уведомление** преподавателя о совпадениях выше порога (заметка/note на ячейке Google Sheets). +9. **Админ-API и UI** для просмотра пар и отметки «рассмотрено». +10. **Конфигурация** курсов: секция `plagiarism:` с deprecated-алиасом `moss:`; обновление документации. +11. **Эмпирическая проверка** ограничений Google Drive API (якорь комментария к ячейке Sheets) и видимости notes для роли Reader. +12. **Сравнительный прогон** compare50 и JPlag на реальном корпусе курса ОС; фиксация выбора движка. +13. **Вспомогательный консольный интерфейс** (`scripts/menu.py`) для типовых операций без длинных CLI-команд. +14. **Покрытие** ключевых модулей unit-тестами (pytest). + +**Вне объёма задания (осознанно):** детекция кода, сгенерированного ИИ; автоматическая замена оценки `v` на `x` по решению алгоритма. + +--- + +## 3. Описание входных и выходных данных + +### 3.1. Входные данные + +| Источник | Содержание | +|----------|------------| +| YAML курса (`courses/*.yaml`, `index.yaml`) | Идентификатор курса, GitHub-организация, список лаб, `files:`, секция `plagiarism:` / `moss:` (язык, порог, `additional`, `basefiles`, `local-path`) | +| GitHub API | Содержимое указанных файлов репозиториев `{org}/{github-prefix}-{login}` | +| Запрос сдачи | `course_id`, `group_id` (лист таблицы), `lab_id`, GitHub-логин студента | +| Google Sheets | Текущие оценки; координаты ячейки для note | +| Переменные окружения | `GITHUB_TOKEN`, `CREDENTIALS_FILE`, `ADMIN_*`, `PLAGIARISM_SHADOW_MODE`, пути кэша/БД | +| Локальный кэш | Ранее скачанные работы текущего и прошлых семестров (`additional`) | + +**Программные библиотеки / инструменты (исходный стек задачи):** + +- **compare50** — локальный инструмент сравнения исходного кода (Harvard CS50); основной движок антиплагиата: оценивает структурное сходство работ и возвращает пары студентов с метрикой similarity. +- **FastAPI** — веб-фреймворк backend; обрабатывает сдачу лаб (`grade_lab`), admin API списка совпадений и запускает фоновую проверку через `BackgroundTasks`. +- **gspread** — клиент Google Sheets; запись/чтение оценок и установка **note** (заметки) на ячейке при срабатывании порога плагиата. +- **oauth2client** — авторизация сервисного аккаунта Google; получение access token для Sheets/Drive API. +- **requests** — HTTP-клиент; обращения к GitHub API (скачивание файлов репозиториев) и при необходимости к Google API. +- **PyYAML** — разбор конфигурации курсов (`courses/*.yaml`, `index.yaml`), в т.ч. секций `plagiarism:` / `moss:`. +- **sqlite3** — встроенная БД Python; хранение пар совпадений в `plagiarism.db` (similarity, review-флаг и т.д.). +- **nbconvert** — конвертация Jupyter-ноутбуков `.ipynb` → `.py` перед сравнением (актуально для курсов с ноутбуками в `files:`). + +Для исследования / сравнения движков: + +- **JPlag** — локальный детектор плагиата (KIT); использовался для сравнительного прогона с compare50 на реальных C/C++ работах курса ОС. +- **OpenJDK** — среда выполнения Java, необходимая для запуска CLI JPlag. + +### 3.2. Выходные данные + +| Канал | Содержание | +|-------|------------| +| Google Sheets | Оценка **без изменений** логики грейда; при similarity ≥ порога — **note** на ячейке с парой(ами) и процентом | +| `plagiarism_cache/` | Файлы исходников и HTML-отчёты compare50 | +| `plagiarism.db` (SQLite) | Пары `(student_a, student_b, similarity, details, checked_at, reviewed_by_teacher)` | +| REST API (admin) | Список совпадений; отметка review | +| Админ-UI | Таблица пар выше порога | +| Логи | Ход фоновой проверки, режим SHADOW | +| Консоль (`menu.py`) | Интерактивный запуск оценки / batch / просмотр БД / сравнение движков | + +Порог по умолчанию: **0.6** (60%). В режиме `PLAGIARISM_SHADOW_MODE=true` совпадения пишутся в БД, notes в таблицу не создаются (пилот). + +--- + +## 4. Используемые технологии и языки программирования + +| Слой | Технологии | +|------|------------| +| Языки | Python 3.12, JavaScript/JSX (React 19), YAML | +| Backend | FastAPI, Uvicorn, BackgroundTasks | +| Движок плагиата | compare50 (Python API, structure pass) | +| Данные | SQLite (`sqlite3`), файловый кэш | +| Интеграции | GitHub REST API, Google Sheets (gspread), сервисный аккаунт Google | +| Frontend | React, Vite, React Router, i18next | +| DevOps / окружение | Docker Compose (volume `plagiarism_cache`), dotenv | +| Тесты | pytest | +| Исследование | JPlag 6.2 (Java/OpenJDK), openpyxl (проверка экспорта comments/notes) | +| Утилиты | `scripts/menu.py`, batch/grade/compare скрипты | + +--- + +## 5. Результат выполнения работы + +### 5.1. Архитектура решения + +Проверка запускается **после** успешной записи оценки `v...` в таблицу и выполняется в фоне. + +```mermaid +flowchart TB + UI[Веб-UI / CLI] + Grade["POST .../grade"] + CI[Проверки GitHub CI] + Sheets[(Google Sheets: оценка v)] + BG[BackgroundTasks] + Cache[Кэш исходников] + GH[GitHub API] + Eng[compare50] + DB[(plagiarism.db)] + Note[Note на ячейке] + AdminAPI[Admin REST API] + AdminUI[Админ-страница пар] + + UI --> Grade + Grade --> CI + CI --> Sheets + Sheets --> BG + BG --> Cache + Cache --> GH + Cache --> Eng + Eng --> DB + Eng --> Note + Note --> Sheets + DB --> AdminAPI + AdminAPI --> AdminUI +``` + +**Основные модули** (по роли в контуре проверки): + +1. **`grading/plagiarism_cache.py`** — слой локального кэша исходников. + Скачивает из GitHub **только файлы**, указанные в конфиге лабы (`files:`), и раскладывает их по пути + `plagiarism_cache/{курс}/{лаба}/{org}/{репозиторий}/`. + При необходимости конвертирует Jupyter-ноутбуки `.ipynb` → `.py`, чтобы движок сравнивал код, а не JSON ноутбука. + Кэш постоянный: повторно уже скачанные работы не тянутся с GitHub без нужды; сюда же попадают работы прошлых лет (`additional`). + +2. **`grading/plagiarism.py`** — обёртка над движком **compare50**. + Собирает директории сабмитов, запускает сравнение (structure pass), приводит сырые scores к диапазону **0…1** (относительно максимума в текущем прогоне) и возвращает нормализованный список пар `(студент_A, студент_B, similarity)`. + Поддерживает исключение шаблонного кода (`basefiles`) и режим «одна новая работа против уже накопленного корпуса» (инкрементальная проверка). + Читает настройки лабы из секций `plagiarism:` / устаревшего алиаса `moss:` (порог, engine и т.д.). + +3. **`grading/plagiarism_store.py`** — хранилище результатов в **SQLite** (`plagiarism.db`). + Записывает и обновляет пары совпадений, хранит путь к детальному отчёту, время проверки и флаг **`reviewed_by_teacher`** (преподаватель уже разобрал пару вручную — при пересчётах флаг не сбрасывается). + Отдаёт выборки для admin API (в т.ч. фильтр по порогу similarity). + +4. **`grading/plagiarism_check.py`** — оркестратор фоновой проверки. + Вызывается после успешной записи `v...` в таблицу. По шагам: читает конфиг лабы → при необходимости подгружает `additional`/basefiles → кэширует работу студента → запускает compare50 → пишет совпадения в БД → если similarity ≥ порога и **не** включён `PLAGIARISM_SHADOW_MODE`, вызывает уведомление преподавателя. + Именно этот модуль связывает кэш, движок, БД и Sheets в один сценарий. + +5. **`grading/sheets_comments.py`** — уведомление в Google Таблице. + Формирует русский текст («возможный плагиат», логины, проценты) и ставит **note** (заметку) на ячейку оценки через gspread. + Саму оценку (`v`, `v-3`, …) **не меняет**. + (Изначально планировались Drive-комментарии с тредом; из‑за ограничений API оставлены только notes.) + +6. **`main.py`** — точка встраивания в существующий сервис. + В `grade_lab` после успешной записи оценки планирует `plagiarism_check` через FastAPI **`BackgroundTasks`**, чтобы студент сразу получил ответ, а тяжёлое сравнение шло в фоне. + Также объявляет admin-эндпоинты: + `GET .../plagiarism` (список пар) и `POST .../plagiarism/review` (отметить пару рассмотренной). + +7. **`frontend/courses-front/.../plagiarism-matches`** — страница админ-панели. + После входа в `/admin` преподаватель выбирает курс и лабораторную и видит таблицу пар выше порога (логины, процент, статус review) с возможностью отметить пару как разобранную. + Данные берутся из backend API; отдельного доступа студентов к этому экрану нет. + +**Структура кэша на диске:** +`plagiarism_cache/{course_id}/{lab_id}/{org}/{repo}/{filename}` + +### 5.2. UML: последовательность при сдаче + +```mermaid +sequenceDiagram + participant S as Студент + participant API as FastAPI grade_lab + participant Sh as Google Sheets + participant BG as Background task + participant GH as GitHub + participant C50 as compare50 + participant DB as SQLite + + S->>API: Сдать лабу (github login) + API->>API: CI / variant / files checks + API->>Sh: Записать v... + API-->>S: Ответ (успех) + API->>BG: schedule plagiarism_check + BG->>GH: Скачать files: в кэш + BG->>C50: Новая работа vs корпус + C50-->>BG: Пары + similarity + BG->>DB: upsert matches + alt similarity ≥ threshold и не SHADOW + BG->>Sh: update_note на ячейке оценки + end +``` + +### 5.3. Уведомления преподавателя + +По исходному плану предполагались **discussion comments** к ячейке (Drive API v3) с поддержкой тредов. Эмпирически установлено: + +- публичный API **не создаёт** надёжно cell-anchored comments в Google Sheets; +- поле `anchor` сохраняется, но UI не привязывает тред к ячейке; +- **notes** видны при наведении на ячейку, в том числе роли Reader. + +**Принятое решение:** использовать только **notes**; review — через админ-панель. + +### 5.4. Выбор движка (численные результаты) + +Сравнение на кэше курса `os-2025-spring` (C++, JPlag 6.2.0 с `--normalize`, скрипт `scripts/compare_engines_jplag_compare50.py`): + +| Лаба | Число сабмитов | Пересечение Top-25 | Пар ≥ 0.6 (compare50) | Пар ≥ 0.6 (JPlag) | Пересечение ≥ 0.6 | +|------|----------------|--------------------|------------------------|-------------------|-------------------| +| ЛР2 | ~98 | 7 / 25 | 49 | 295 | 39 | +| ЛР3 | ~185 | 10 / 25 | 3 | 773 | 3 | + +**Интерпретация:** + +- явные клоны оба инструмента выделяют в топе ранжирования; +- JPlag даёт **абсолютную** AVG-схожесть; без исключения base code порог 0.6 на типовых OS-лабах даёт сотни срабатываний (общий каркас задания); +- compare50 в обёртке использует **относительную** нормализацию к max в прогоне — удобнее для инкрементального режима, дешевле в эксплуатации (`pip`, без JRE). + +**Решение:** основной движок в продакшен-контуре — **compare50**. JPlag оставлен как кандидат на отдельную итерацию (JRE + обязательный base code). + +### 5.5. Пользовательские интерфейсы + +1. **Админ-панель (веб):** `/admin` → курс → лабораторная → список пар плагиата (similarity, студенты, review). +2. **Google Sheets:** жёлтый индикатор note на ячейке оценки при срабатывании порога. +3. **Консольное меню:** `python scripts/menu.py` — оценка студента, batch-плагиат, просмотр SQLite, сравнение движков, статус окружения. + +*(В финальную печатную версию отчёта рекомендуется вставить скриншоты: страница `/admin/.../plagiarism`, note в таблице, окно `menu.py`.)* + +### 5.6. Конфигурация (фрагмент) + +```yaml +plagiarism: + engine: compare50 + language: cpp # идентификатор для конфига / будущих движков + threshold: 0.6 + additional: + - suai-os-2024 + basefiles: + - repo: teacher/templates + filename: lab3.cpp +``` + +Устаревший ключ `moss:` поддерживается как алиас. + +--- + +## 6. Выводы + +1. Реализована **локальная инкрементальная** проверка на плагиат в момент успешной сдачи работы, без batch-отправки корпуса в MOSS. +2. Решение встроено в существующий контур lab_grader_web: кэш → compare50 → SQLite → note в Sheets → админ-UI. +3. Показано, что **cell-anchored Drive comments** в Google Sheets через публичные API недоступны; практичный канал флага — **cell notes** (видимы Reader). +4. Сравнение с JPlag на реальных C++-лабах подтвердило целесообразность **compare50** для текущего Docker-стека при сопоставимом качестве ловли сильных клонов и меньшей стоимости сопровождения. +5. Режим `PLAGIARISM_SHADOW_MODE` позволяет безопасно пилотировать детекцию без уведомлений в таблице. +6. Фича **флагирует** подозрительные пары для ручного разбора преподавателем и не подменяет академическое решение алгоритмом. + +**Направления развития:** подключение JPlag с обязательным `-bc`/basefiles; уточнение шкалы similarity (абсолютные vs относительные scores); опциональная миграция YAML с `moss:` на `plagiarism:`. + +--- + +## 7. Список использованных источников + +1. Документ проекта: *План разработки: автоматическая проверка на плагиат* (`docs/PLAGIARISM_DETECTION_PLAN.md`). +2. CS50 Compare50 Documentation. URL: https://cs50.readthedocs.io/projects/compare50/ +3. JPlag — Source Code Plagiarism Detection. GitHub: https://github.com/jplag/JPlag +4. Google Drive API: Manage comments and replies. URL: https://developers.google.com/workspace/drive/api/guides/manage-comments +5. Aiken A. *MOSS: A System for Detecting Software Similarity* (Stanford). +6. FastAPI Documentation. URL: https://fastapi.tiangolo.com/ +7. Документация проекта lab_grader_web: `docs/PROJECT_DESCRIPTION.md`, `docs/COURSE_CONFIG.md`. + +--- + +## 8. Приложения + +**Приложение А.** Карта модулей антиплагиата в репозитории: + +- `grading/plagiarism_cache.py` +- `grading/plagiarism.py` +- `grading/plagiarism_store.py` +- `grading/plagiarism_check.py` +- `grading/sheets_comments.py` +- `scripts/menu.py` +- `scripts/grade_student_labs.py` +- `scripts/plagiarism_batch_course.py` +- `scripts/compare_engines_jplag_compare50.py` +- `frontend/courses-front/src/components/plagiarism-matches/` +- тесты: `tests/test_plagiarism*.py`, `tests/test_sheets_comments.py` + +**Приложение Б.** Сводная таблица фаз плана и статуса выполнения: + +| Фаза | Содержание | Статус | +|------|------------|--------| +| 0 | Исследование движков и Sheets comments | Выполнено | +| 1 | Кэш исходников | Выполнено | +| 2 | Обёртка движка | Выполнено (compare50) | +| 3 | Встраивание в грейдинг + SQLite | Выполнено | +| 4 | Отчётность (notes + админка) | Выполнено (notes вместо Drive comments) | +| 5 | Конфиг и документация | Выполнено | +| 6 | Тесты и пилот | Выполнено | + +**Приложение В.** *(место для скриншотов UI и note в Google Sheets)* diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 3a6f66d..67c3337 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -15,7 +15,7 @@ - **Управление курсами** — поддержка множественных курсов с отдельными настройками - **Интеграция с Google Sheets** — централизованное хранение данных о студентах и оценках - **Проверка качества кода** — анализ результатов тестирования и линтинга -- **Антиплагиат** — конфигурация проверки через MOSS (Measure Of Software Similarity) +- **Антиплагиат** — автоматическая проверка при сдаче через локальный compare50 (конфиг `plagiarism:` / устаревший `moss:`), кэш исходников и SQLite; флаги для преподавателя без изменения оценки - **Административная панель** — управление конфигурациями курсов через веб-интерфейс ## Технологический стек @@ -367,10 +367,13 @@ score: - `lab-column-offset`: Смещение между столбцом студента и первой лабораторной работой - Примечание: В коде автоматически добавляется +1 для совместимости с gspread (1-based индексация) -### MOSS (Антиплагиат) -- Конфигурация языка программирования -- Настройка максимального количества совпадений -- Интеграция для проверки оригинальности кода +### Plagiarism / Антиплагиат +- Локальный движок **compare50** (секция `plagiarism:` или deprecated `moss:`) +- Кэш исходников `plagiarism_cache/` + SQLite `plagiarism.db` +- Фоновая проверка при успешной сдаче (`BackgroundTasks` в `grade_lab`) +- Уведомление: **note** на ячейке оценки (оценка не меняется) +- API списка совпадений: `GET /courses/{course_id}/labs/{lab_id}/plagiarism` +- Пилотный режим: `PLAGIARISM_SHADOW_MODE=true` (результаты пишутся в БД без уведомлений) ## Развертывание diff --git a/frontend/courses-front/src/App.jsx b/frontend/courses-front/src/App.jsx index 7e830e6..f376498 100644 --- a/frontend/courses-front/src/App.jsx +++ b/frontend/courses-front/src/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { BrowserRouter as Router, Routes, Route, useNavigate } from "react-router-dom"; import { CourseList } from "./components/course-list"; import { AdminLogin } from "./components/admin/AdminLogin"; import { ProtectedRoute } from "./components/admin/ProtectedRoute"; @@ -6,6 +6,18 @@ import { CourseListWrapper } from "./components/course-list/courseListWrapper"; import { GroupListWrapper } from "./components/group-list/groupListWrapper"; import { LabListWrapper } from "./components/lab-list/labListWrapper"; import { RegistrationFormWrapper } from "./components/registration-form/registrationFormWrapper"; +import { PlagiarismMatches } from "./components/plagiarism-matches"; +import { AdminLabList } from "./components/plagiarism-matches/adminLabList"; + +function AdminCoursesPage() { + const navigate = useNavigate(); + return ( + navigate(`/admin/courses/${courseId}/labs`)} + /> + ); +} function App() { return ( @@ -16,7 +28,23 @@ function App() { path="/admin/courses" element={ - {}} isAdmin={true} /> + + + } + /> + + + + } + /> + + } /> diff --git a/frontend/courses-front/src/components/plagiarism-matches/adminLabList.jsx b/frontend/courses-front/src/components/plagiarism-matches/adminLabList.jsx new file mode 100644 index 0000000..5ceb193 --- /dev/null +++ b/frontend/courses-front/src/components/plagiarism-matches/adminLabList.jsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { fetchCourseDetails } from "../../api"; +import { + BackLink, + Empty, + ErrorText, + Header, + LabCard, + LabGrid, + LabMeta, + LabName, + Page, + Subtitle, + Title, +} from "./styled"; + +export const AdminLabList = () => { + const { courseId } = useParams(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const [course, setCourse] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + fetchCourseDetails(courseId) + .then((details) => { + setCourse(details); + setError(""); + }) + .catch((err) => { + setError(err.message || t("errorLoadingCourseDetails")); + setCourse(null); + }) + .finally(() => setLoading(false)); + }, [courseId, t]); + + const labs = course?.labs || []; + + return ( + +
+
+ {t("plagiarismLabs")} + {course?.name || courseId} +
+ navigate("/admin/courses")}> + {t("backToCourses")} + +
+ + {loading && {t("loading")}} + {error && {error}} + {!loading && !error && labs.length === 0 && ( + {t("noLabs")} + )} + + + {labs.map((lab) => ( + + navigate(`/admin/courses/${courseId}/labs/${lab.id}/plagiarism`) + } + > + {lab["short-name"] || lab.id} + + id: {lab.id} + {lab.has_plagiarism ? ` · ${t("plagiarismConfigured")}` : ""} + + + ))} + +
+ ); +}; diff --git a/frontend/courses-front/src/components/plagiarism-matches/index.jsx b/frontend/courses-front/src/components/plagiarism-matches/index.jsx new file mode 100644 index 0000000..b332639 --- /dev/null +++ b/frontend/courses-front/src/components/plagiarism-matches/index.jsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + ActionButton, + BackLink, + Empty, + ErrorText, + Filters, + Header, + Page, + Subtitle, + Table, + Title, +} from "./styled"; + +export const PlagiarismMatches = () => { + const { courseId, labId } = useParams(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [includeReviewed, setIncludeReviewed] = useState(true); + const [busyPair, setBusyPair] = useState(null); + + const load = async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams({ + include_reviewed: String(includeReviewed), + }); + const response = await fetch( + `/api/v1/courses/${courseId}/labs/${labId}/plagiarism?${params}`, + { credentials: "include" } + ); + if (response.status === 401) { + navigate("/admin"); + return; + } + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail || t("errorLoadingPlagiarism")); + } + setData(await response.json()); + } catch (err) { + setError(err.message || t("errorLoadingPlagiarism")); + setData(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [courseId, labId, includeReviewed]); + + const markReviewed = async (studentA, studentB, reviewed) => { + const key = `${studentA}:${studentB}`; + setBusyPair(key); + try { + const response = await fetch( + `/api/v1/courses/${courseId}/labs/${labId}/plagiarism/review`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + student_a: studentA, + student_b: studentB, + reviewed, + }), + } + ); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail || t("errorReviewingPlagiarism")); + } + await load(); + } catch (err) { + setError(err.message || t("errorReviewingPlagiarism")); + } finally { + setBusyPair(null); + } + }; + + const matches = data?.matches || []; + + return ( + +
+
+ {t("plagiarismMatches")} + + {courseId} · {t("lab")} {labId} + {data?.threshold != null + ? ` · ${t("threshold")}: ${Math.round(data.threshold * 100)}%` + : ""} + +
+ navigate(`/admin/courses/${courseId}/labs`)} + > + {t("backToLabs")} + +
+ + + + + + {loading && {t("loading")}} + {error && {error}} + + {!loading && !error && matches.length === 0 && ( + {t("noPlagiarismMatches")} + )} + + {!loading && matches.length > 0 && ( + + + + + + + + + + + + {matches.map((m) => { + const key = `${m.student_a}:${m.student_b}`; + return ( + + + + + + + + + ); + })} + +
{t("studentA")}{t("studentB")}{t("similarity")}{t("checkedAt")}{t("reviewed")} +
{m.student_a}{m.student_b}{Math.round(m.similarity * 100)}% + {m.checked_at + ? new Date(m.checked_at).toLocaleString() + : "—"} + + {m.reviewed_by_teacher ? t("yes") : t("no")} + + + markReviewed( + m.student_a, + m.student_b, + !m.reviewed_by_teacher + ) + } + > + {m.reviewed_by_teacher + ? t("unmarkReviewed") + : t("markReviewed")} + +
+ )} +
+ ); +}; diff --git a/frontend/courses-front/src/components/plagiarism-matches/styled.js b/frontend/courses-front/src/components/plagiarism-matches/styled.js new file mode 100644 index 0000000..85267ff --- /dev/null +++ b/frontend/courses-front/src/components/plagiarism-matches/styled.js @@ -0,0 +1,145 @@ +import styled from "styled-components"; +import { colors, sizes, textStyles, breakpoints } from "../../../theme"; + +export const Page = styled.div` + max-width: 900px; + margin: 0 auto; + padding: 24px 16px 48px; +`; + +export const Header = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 24px; +`; + +export const Title = styled.h1` + ${textStyles} + font-size: ${sizes.fontSizeLarge}; + margin: 0; + color: ${colors.textPrimary}; +`; + +export const Subtitle = styled.p` + ${textStyles} + font-size: ${sizes.fontSizeMedium}; + color: ${colors.textSecondary}; + margin: 4px 0 0; +`; + +export const BackLink = styled.button` + ${textStyles} + background: none; + border: 1px solid ${colors.buttonBorder}; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + color: ${colors.buttonBorder}; + + &:hover { + background: ${colors.buttonHover}; + } +`; + +export const LabGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +`; + +export const LabCard = styled.button` + ${textStyles} + text-align: left; + padding: 14px 16px; + border: 1px solid ${colors.buttonBorder}; + border-radius: 10px; + background: #fff; + cursor: pointer; + + &:hover { + background: ${colors.buttonHover}; + } +`; + +export const LabName = styled.div` + font-size: ${sizes.fontSizeMedium}; + color: ${colors.textPrimary}; +`; + +export const LabMeta = styled.div` + font-size: ${sizes.fontSizeSmall}; + color: ${colors.textSecondary}; + margin-top: 4px; +`; + +export const Table = styled.table` + width: 100%; + border-collapse: collapse; + ${textStyles} + font-size: ${sizes.fontSizeMedium}; + + th, + td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + } + + th { + color: ${colors.textSecondary}; + font-weight: 600; + } + + @media (max-width: ${breakpoints.tablet}) { + font-size: ${sizes.fontSizeSmall}; + + th, + td { + padding: 8px 6px; + } + } +`; + +export const Empty = styled.p` + ${textStyles} + color: ${colors.textSecondary}; + margin-top: 24px; +`; + +export const ErrorText = styled.p` + ${textStyles} + color: ${colors.error}; + margin-top: 16px; +`; + +export const ActionButton = styled.button` + ${textStyles} + font-size: ${sizes.fontSizeSmall}; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid ${colors.buttonBorder}; + background: #fff; + cursor: pointer; + + &:hover { + background: ${colors.buttonHover}; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +`; + +export const Filters = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-bottom: 16px; + ${textStyles} + font-size: ${sizes.fontSizeMedium}; +`; diff --git a/frontend/courses-front/src/locales/en/translation.json b/frontend/courses-front/src/locales/en/translation.json index f02296b..a9f86fe 100644 --- a/frontend/courses-front/src/locales/en/translation.json +++ b/frontend/courses-front/src/locales/en/translation.json @@ -17,11 +17,36 @@ "logout": "Logout", "confirmDeleteTitle": "Delete confirmation", "confirmDeleteText": "Are you sure you want to delete this course?", + "confirmDeleteMessage": "Are you sure you want to delete this course?", "yes": "Yes", "no": "No", "expand": "Expand", "information": "Information", "activeCourses": "Active Courses", "archivedCourses": "Archived Courses", - "allCourses": "All Courses" + "allCourses": "All Courses", + "errorLoadingCourses": "Error loading courses", + "errorLoadingCourseDetails": "Error loading course details", + "errorDeletingCourse": "Error deleting course", + "courseDeleted": "Course deleted", + "plagiarismMatches": "Plagiarism matches", + "plagiarismLabs": "Labs — plagiarism", + "plagiarismConfigured": "check configured", + "studentA": "Student A", + "studentB": "Student B", + "similarity": "Similarity", + "checkedAt": "Checked at", + "reviewed": "Reviewed", + "markReviewed": "Mark reviewed", + "unmarkReviewed": "Unmark", + "showReviewed": "Show reviewed", + "noPlagiarismMatches": "No matches above threshold", + "errorLoadingPlagiarism": "Failed to load plagiarism matches", + "errorReviewingPlagiarism": "Failed to update review status", + "backToCourses": "Back to courses", + "backToLabs": "Back to labs", + "lab": "Lab", + "threshold": "Threshold", + "loading": "Loading…", + "noLabs": "No labs in this course" } diff --git a/frontend/courses-front/src/locales/ru/translation.json b/frontend/courses-front/src/locales/ru/translation.json index bdc4e25..45a69fa 100644 --- a/frontend/courses-front/src/locales/ru/translation.json +++ b/frontend/courses-front/src/locales/ru/translation.json @@ -17,11 +17,36 @@ "logout": "Выйти", "confirmDeleteTitle": "Подтверждение удаления", "confirmDeleteText": "Вы уверены, что хотите удалить этот курс?", + "confirmDeleteMessage": "Вы уверены, что хотите удалить этот курс?", "yes": "Да", "no": "Нет", "expand": "Развернуть", "information": "Информация", "activeCourses": "Активные курсы", "archivedCourses": "Архив курсов", - "allCourses": "Все курсы" + "allCourses": "Все курсы", + "errorLoadingCourses": "Ошибка загрузки курсов", + "errorLoadingCourseDetails": "Ошибка загрузки деталей курса", + "errorDeletingCourse": "Ошибка удаления курса", + "courseDeleted": "Курс удалён", + "plagiarismMatches": "Проверка на плагиат", + "plagiarismLabs": "Лабораторные — плагиат", + "plagiarismConfigured": "проверка настроена", + "studentA": "Студент A", + "studentB": "Студент B", + "similarity": "Сходство", + "checkedAt": "Проверено", + "reviewed": "Рассмотрено", + "markReviewed": "Отметить", + "unmarkReviewed": "Снять отметку", + "showReviewed": "Показывать рассмотренные", + "noPlagiarismMatches": "Совпадений выше порога нет", + "errorLoadingPlagiarism": "Не удалось загрузить совпадения", + "errorReviewingPlagiarism": "Не удалось обновить статус", + "backToCourses": "К курсам", + "backToLabs": "К лабораторным", + "lab": "ЛР", + "threshold": "Порог", + "loading": "Загрузка…", + "noLabs": "В курсе нет лабораторных" } diff --git a/frontend/courses-front/src/locales/zh/translation.json b/frontend/courses-front/src/locales/zh/translation.json index d33b3f9..10be2f9 100644 --- a/frontend/courses-front/src/locales/zh/translation.json +++ b/frontend/courses-front/src/locales/zh/translation.json @@ -22,9 +22,34 @@ "courseDeleted": "课程已删除", "changesSaved": "更改已保存", "confirmDelete": "确认删除", + "confirmDeleteTitle": "确认删除", + "confirmDeleteText": "您确定要删除此课程吗?", "confirmDeleteMessage": "您确定要删除此课程吗?", "expand": "展开", "activeCourses": "活跃课程", "archivedCourses": "归档课程", - "allCourses": "所有课程" + "allCourses": "所有课程", + "yes": "是", + "no": "否", + "logout": "退出", + "plagiarismMatches": "抄袭检测结果", + "plagiarismLabs": "实验 — 抄袭检测", + "plagiarismConfigured": "已配置检测", + "studentA": "学生 A", + "studentB": "学生 B", + "similarity": "相似度", + "checkedAt": "检测时间", + "reviewed": "已审阅", + "markReviewed": "标记已审阅", + "unmarkReviewed": "取消标记", + "showReviewed": "显示已审阅", + "noPlagiarismMatches": "没有超过阈值的匹配", + "errorLoadingPlagiarism": "无法加载抄袭匹配", + "errorReviewingPlagiarism": "无法更新审阅状态", + "backToCourses": "返回课程", + "backToLabs": "返回实验", + "lab": "实验", + "threshold": "阈值", + "loading": "加载中…", + "noLabs": "该课程没有实验" } diff --git a/grading/github_client.py b/grading/github_client.py index e668661..b9ced30 100644 --- a/grading/github_client.py +++ b/grading/github_client.py @@ -4,10 +4,14 @@ This module provides a client for interacting with GitHub API to check repositories, commits, and CI status. """ +import base64 +import logging import requests from dataclasses import dataclass from typing import Any +logger = logging.getLogger(__name__) + @dataclass class CommitInfo: @@ -78,6 +82,52 @@ def file_exists(self, org: str, repo: str, path: str) -> bool: resp = requests.get(url, headers=self.headers) return resp.status_code == 200 + def get_file_content(self, org: str, repo: str, path: str) -> bytes | None: + """ + Download raw file content from a repository. + + Args: + org: Organization or user name + repo: Repository name + path: File path within repository + + Returns: + File content as bytes, or None if the file is missing / not a file + """ + url = f"{self.BASE_URL}/repos/{org}/{repo}/contents/{path}" + resp = requests.get(url, headers=self.headers) + + if resp.status_code != 200: + logger.debug( + "Failed to get %s/%s/%s: HTTP %s", + org, repo, path, resp.status_code, + ) + return None + + data = resp.json() + if isinstance(data, list): + # Path points to a directory, not a file + logger.debug("%s/%s/%s is a directory, not a file", org, repo, path) + return None + + encoding = data.get("encoding") + content = data.get("content") + if encoding == "base64" and content: + return base64.b64decode(content) + + # Large files may omit inline content; fall back to download_url + download_url = data.get("download_url") + if download_url: + download_resp = requests.get(download_url, headers=self.headers) + if download_resp.status_code == 200: + return download_resp.content + + logger.warning( + "Could not decode content for %s/%s/%s (encoding=%s)", + org, repo, path, encoding, + ) + return None + def check_required_files( self, org: str, @@ -199,6 +249,41 @@ def get_job_logs(self, org: str, repo: str, job_id: int) -> str | None: resp.encoding = 'utf-8' return resp.text + def list_repos_with_prefix(self, org: str, prefix: str) -> list[str]: + """ + List repository names in an organization that start with ``prefix-`` + or exactly ``prefix`` (for cross-semester plagiarism cache prefill). + + Returns bare repo names (without org/), paginating through GitHub API. + """ + repos: list[str] = [] + page = 1 + expected = f"{prefix}-" + while True: + url = f"{self.BASE_URL}/orgs/{org}/repos" + resp = requests.get( + url, + headers=self.headers, + params={"per_page": 100, "page": page, "type": "all"}, + ) + if resp.status_code != 200: + logger.warning( + "Failed to list repos for org %s: HTTP %s", + org, resp.status_code, + ) + break + batch = resp.json() + if not batch: + break + for item in batch: + name = item.get("name", "") + if name.startswith(expected) or name == prefix: + repos.append(name) + if len(batch) < 100: + break + page += 1 + return repos + def check_forbidden_modifications( commit_files: list[dict[str, Any]], diff --git a/grading/plagiarism.py b/grading/plagiarism.py new file mode 100644 index 0000000..57ce162 --- /dev/null +++ b/grading/plagiarism.py @@ -0,0 +1,367 @@ +""" +Plagiarism detection engine wrapper. + +Uses compare50's Python API (not the CLI) so it works both in Linux Docker +and on Windows during development. Scores are normalized to [0, 1] relative +to the max raw score in the current run (same approach as compare50's HTML UI). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from .plagiarism_cache import ( + extract_student_from_repo, + get_cache_root, + list_cached_submissions, +) + +logger = logging.getLogger(__name__) + +DEFAULT_PLAGIARISM_THRESHOLD = 0.6 +DEFAULT_ENGINE = "compare50" +DEFAULT_MAX_MATCHES = 50 + +# File extensions considered for comparison (ipynb already converted to .py in cache) +COMPARABLE_SUFFIXES = { + ".py", ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx", + ".java", ".js", ".ts", ".go", ".rs", ".cs", ".rb", ".php", + ".swift", ".kt", ".kts", ".m", ".mm", ".scala", ".sh", ".bash", + ".r", ".R", ".sql", ".txt", ".md", +} + + +@dataclass +class PlagiarismMatch: + """Normalized plagiarism match between two students.""" + + student_a: str + student_b: str + similarity: float # 0..1 + details: str = "" + raw_score: float = 0.0 + + +@dataclass +class PlagiarismConfig: + """Resolved plagiarism settings for a lab (from plagiarism: or moss:).""" + + engine: str = DEFAULT_ENGINE + language: str | None = None + threshold: float = DEFAULT_PLAGIARISM_THRESHOLD + local_path: str | None = None + additional: list[str] | None = None + basefiles: list[dict[str, str]] | None = None + max_matches: int = DEFAULT_MAX_MATCHES + enabled: bool = True + + +def get_plagiarism_config(lab_config: dict[str, Any] | None) -> PlagiarismConfig | None: + """ + Resolve plagiarism settings from lab config. + + Prefers `plagiarism:`; falls back to deprecated `moss:` alias. + Returns None if neither section is present (lab has no plagiarism check). + """ + if not lab_config: + return None + + raw = lab_config.get("plagiarism") + if raw is None: + raw = lab_config.get("moss") + if raw is None: + return None + if raw is False: + return PlagiarismConfig(enabled=False) + if not isinstance(raw, dict): + return PlagiarismConfig() + + threshold = raw.get("threshold", DEFAULT_PLAGIARISM_THRESHOLD) + try: + threshold = float(threshold) + except (TypeError, ValueError): + threshold = DEFAULT_PLAGIARISM_THRESHOLD + threshold = max(0.0, min(1.0, threshold)) + + max_matches = raw.get("max-matches", DEFAULT_MAX_MATCHES) + try: + max_matches = int(max_matches) + except (TypeError, ValueError): + max_matches = DEFAULT_MAX_MATCHES + + return PlagiarismConfig( + engine=str(raw.get("engine", DEFAULT_ENGINE)).lower(), + language=raw.get("language"), + threshold=threshold, + local_path=raw.get("local-path"), + additional=list(raw.get("additional") or []), + basefiles=list(raw.get("basefiles") or []), + max_matches=max(1, max_matches), + enabled=True, + ) + + +def _collect_comparable_files(directory: Path) -> list[str]: + """Return relative paths of comparable source files under directory.""" + files: list[str] = [] + if not directory.is_dir(): + return files + for path in sorted(directory.rglob("*")): + if not path.is_file(): + continue + if path.suffix.lower() not in COMPARABLE_SUFFIXES: + continue + # Prefer converted .py over original .ipynb (ipynb not in suffix list anyway) + files.append(str(path.relative_to(directory)).replace("\\", "/")) + return files + + +def _student_from_submission_dir(submission_dir: Path, github_prefix: str | None = None) -> str: + """Derive student login from `{org}/{repo}` cache directory.""" + repo = submission_dir.name + if github_prefix: + return extract_student_from_repo(repo, github_prefix) + # Heuristic: last segment after final '-' + if "-" in repo: + return repo.rsplit("-", 1)[-1] + return repo + + +def _build_compare50_submission( + directory: Path, + preprocessor, + *, + is_archive: bool = False, +): + """Build a compare50.Submission from a cached directory.""" + from compare50 import Submission + + files = _collect_comparable_files(directory) + if not files: + return None + return Submission( + directory, + files, + preprocessor=preprocessor, + is_archive=is_archive, + ) + + +def _normalize_scores(raw_scores: list[tuple[str, str, float]]) -> list[PlagiarismMatch]: + """Normalize raw engine scores to similarity in [0, 1].""" + if not raw_scores: + return [] + max_score = max(score for _, _, score in raw_scores) + if max_score <= 0: + return [ + PlagiarismMatch(a, b, 0.0, raw_score=score) + for a, b, score in raw_scores + ] + return [ + PlagiarismMatch( + student_a=a, + student_b=b, + similarity=round(score / max_score, 4), + raw_score=score, + ) + for a, b, score in raw_scores + ] + + +def run_compare50( + submission_dirs: list[Path], + *, + archive_dirs: list[Path] | None = None, + distro_dirs: list[Path] | None = None, + max_matches: int = DEFAULT_MAX_MATCHES, + github_prefix: str | None = None, + output_dir: Path | None = None, +) -> list[PlagiarismMatch]: + """ + Compare submissions with compare50 structure pass. + + Args: + submission_dirs: Primary submissions (cross-compared with each other + and against archives) + archive_dirs: Archive submissions (e.g. previous years / other students + when doing incremental check). Not cross-compared with each other. + distro_dirs: Base/template code directories to exclude from matching + max_matches: Max number of top pairs to keep + github_prefix: Repo prefix for extracting student logins + output_dir: Optional directory for HTML report (index.html) + + Returns: + Normalized list of PlagiarismMatch ordered by similarity desc + """ + from compare50 import rank, compare, _api, _data + from compare50._data import Preprocessor + from compare50.passes import structure + from compare50._renderer import render as render_results + + # Windows default encoding (cp1251 etc.) breaks on UTF-8 student sources + def _read_utf8(self, size=-1): + with open(self.path, encoding="utf-8", errors="replace") as f: + return f.read(size) + + _data.File.read = _read_utf8 # type: ignore[method-assign] + + # Serial executor: safe under Windows and FastAPI workers + _api.Executor = _api.FauxExecutor + + archive_dirs = archive_dirs or [] + distro_dirs = distro_dirs or [] + + preprocessor = Preprocessor(structure.preprocessors) + + submissions = [] + for d in submission_dirs: + sub = _build_compare50_submission(d, preprocessor, is_archive=False) + if sub is not None: + submissions.append(sub) + + archives = [] + for d in archive_dirs: + sub = _build_compare50_submission(d, preprocessor, is_archive=True) + if sub is not None: + archives.append(sub) + + ignored_files = set() + for d in distro_dirs: + sub = _build_compare50_submission(d, preprocessor, is_archive=False) + if sub is not None: + ignored_files.update(sub.files) + + if len(submissions) + len(archives) < 2: + logger.info("Not enough submissions to compare (%d + %d)", len(submissions), len(archives)) + return [] + + if not submissions: + logger.info("No primary submissions to compare") + return [] + + with _api.progress_bar("Ranking", disable=True): + scores = rank(submissions, archives, ignored_files, structure, n=max_matches) + + raw: list[tuple[str, str, float]] = [] + for score in scores: + if score.score <= 0: + continue + student_a = _student_from_submission_dir(Path(score.sub_a.path), github_prefix) + student_b = _student_from_submission_dir(Path(score.sub_b.path), github_prefix) + # Stable ordering of pair + if student_a > student_b: + student_a, student_b = student_b, student_a + raw.append((student_a, student_b, float(score.score))) + + matches = _normalize_scores(raw) + + if output_dir is not None and scores: + try: + import builtins + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + with _api.progress_bar("Comparing", disable=True): + pass_to_results = {structure: compare(scores, ignored_files, structure)} + + # compare50 HTML renderer uses open() without encoding; on Windows + # that is often cp1251 and fails on UTF-8 student sources. + _real_open = builtins.open + + def _open_utf8(file, mode="r", *args, **kwargs): + if "b" not in mode and "encoding" not in kwargs: + kwargs["encoding"] = "utf-8" + kwargs.setdefault("errors", "replace") + return _real_open(file, mode, *args, **kwargs) + + builtins.open = _open_utf8 # type: ignore[assignment] + try: + index_path = render_results(pass_to_results, output_dir) + finally: + builtins.open = _real_open # type: ignore[assignment] + + for match in matches: + match.details = str(index_path) + except Exception: + logger.exception("Failed to render compare50 HTML report to %s", output_dir) + + return matches + + +def compare_submission_against_cache( + course_id: str, + lab_id: str, + org: str, + repo: str, + *, + cache_root: str | Path | None = None, + github_prefix: str | None = None, + max_matches: int = DEFAULT_MAX_MATCHES, + basefiles_dir: Path | None = None, + write_report: bool = True, +) -> list[PlagiarismMatch]: + """ + Incremental check: compare one newly cached submission against all others. + + The new submission is the only regular submission; everyone else in the + lab cache is treated as archive (not cross-compared among themselves). + """ + new_dir = get_cache_root(cache_root) / course_id / str(lab_id) / org / repo + if not new_dir.is_dir(): + logger.warning("New submission cache missing: %s", new_dir) + return [] + + others = [ + d for d in list_cached_submissions(course_id, lab_id, cache_root) + if d.resolve() != new_dir.resolve() + ] + if not others: + logger.info("No other submissions in cache for %s/%s — skipping compare", course_id, lab_id) + return [] + + report_dir = None + if write_report: + report_dir = ( + get_cache_root(cache_root) + / course_id + / str(lab_id) + / "_reports" + / f"{org}_{repo}" + ) + + distro = [basefiles_dir] if basefiles_dir and basefiles_dir.is_dir() else [] + + return run_compare50( + [new_dir], + archive_dirs=others, + distro_dirs=distro, + max_matches=max_matches, + github_prefix=github_prefix, + output_dir=report_dir, + ) + + +def filter_matches_above_threshold( + matches: Iterable[PlagiarismMatch], + threshold: float = DEFAULT_PLAGIARISM_THRESHOLD, +) -> list[PlagiarismMatch]: + """Return matches with similarity >= threshold, sorted desc.""" + filtered = [m for m in matches if m.similarity >= threshold] + return sorted(filtered, key=lambda m: m.similarity, reverse=True) + + +def run_plagiarism_engine( + engine: str, + submission_dirs: list[Path], + **kwargs, +) -> list[PlagiarismMatch]: + """Dispatch to the selected plagiarism engine.""" + engine = (engine or DEFAULT_ENGINE).lower() + if engine in ("compare50", "moss"): # moss alias maps to local compare50 for now + return run_compare50(submission_dirs, **kwargs) + raise ValueError( + f"Unsupported plagiarism engine '{engine}'. " + "Supported: compare50 (jplag/dolos not implemented yet)." + ) diff --git a/grading/plagiarism_cache.py b/grading/plagiarism_cache.py new file mode 100644 index 0000000..5deebc2 --- /dev/null +++ b/grading/plagiarism_cache.py @@ -0,0 +1,227 @@ +""" +Local cache of student source files for plagiarism detection. + +Downloads only the files listed in lab config (`files:`) into: + {cache_root}/{course_id}/{lab_id}/{org}/{repo_name}/{filename} + +Notebooks (`.ipynb`) are converted to `.py` before comparison so engines +like compare50/JPlag see source code rather than notebook JSON. +""" +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path + +from .github_client import GitHubClient + +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_ROOT = "plagiarism_cache" + + +@dataclass +class CachedSubmission: + """Paths of files cached for one student repository.""" + + org: str + repo: str + student: str + directory: Path + files: list[Path] + + +def get_cache_root(cache_root: str | Path | None = None) -> Path: + """Resolve plagiarism cache root directory.""" + if cache_root is not None: + return Path(cache_root) + env_root = os.environ.get("PLAGIARISM_CACHE_DIR") + if env_root: + return Path(env_root) + return Path(DEFAULT_CACHE_ROOT) + + +def submission_cache_dir( + course_id: str, + lab_id: str, + org: str, + repo: str, + cache_root: str | Path | None = None, +) -> Path: + """ + Return cache directory for a single submission. + + Layout: {cache_root}/{course_id}/{lab_id}/{org}/{repo}/ + """ + return get_cache_root(cache_root) / course_id / str(lab_id) / org / repo + + +def extract_student_from_repo(repo: str, github_prefix: str) -> str: + """ + Extract GitHub username from repo name `{prefix}-{username}`. + + Falls back to the full repo name if the prefix does not match. + """ + expected = f"{github_prefix}-" + if repo.startswith(expected): + return repo[len(expected):] + return repo + + +def convert_ipynb_to_py(ipynb_path: str | Path, check_existing: bool = False) -> Path: + """ + Convert a Jupyter notebook to a plain Python script (code cells only). + + Uses stdlib JSON parsing — no nbconvert dependency required. + + Args: + ipynb_path: Path to the `.ipynb` file + check_existing: If True and `.py` already exists, reuse it + + Returns: + Path to the generated `.py` file + """ + ipynb_path = Path(ipynb_path) + if ipynb_path.suffix.lower() != ".ipynb": + raise ValueError(f"Input file must be a .ipynb file, got: {ipynb_path}") + + py_path = ipynb_path.with_suffix(".py") + if check_existing and py_path.exists(): + return py_path + + with open(ipynb_path, encoding="utf-8") as f: + notebook = json.load(f) + + code_parts: list[str] = [] + for cell in notebook.get("cells", []): + if cell.get("cell_type") != "code": + continue + source = cell.get("source", []) + if isinstance(source, list): + code_parts.append("".join(source)) + elif isinstance(source, str): + code_parts.append(source) + + py_path.write_text("\n\n".join(code_parts) + ("\n" if code_parts else ""), encoding="utf-8") + logger.debug("Converted %s -> %s", ipynb_path, py_path) + return py_path + + +def _resolve_repo_file_path(filename: str, local_path: str | None) -> str: + """ + Build the path inside the GitHub repository for a configured file. + + If `local_path` is set and `filename` is not already under it, join them. + Grading still checks `files:` as given; plagiarism may use `local-path` + when sources live in a subdirectory (documented moss/plagiarism option). + """ + if not local_path: + return filename + normalized = filename.replace("\\", "/") + prefix = local_path.strip("/").replace("\\", "/") + if normalized == prefix or normalized.startswith(prefix + "/"): + return filename + return f"{prefix}/{filename}" + + +def cache_submission_files( + github: GitHubClient, + course_id: str, + lab_id: str, + org: str, + repo: str, + files: list[str], + *, + local_path: str | None = None, + cache_root: str | Path | None = None, + github_prefix: str | None = None, +) -> CachedSubmission | None: + """ + Download required lab files for one repo into the local plagiarism cache. + + Converts `.ipynb` files to `.py` after download. Skips missing files + (logs a warning) — returns None only if nothing was cached. + + Args: + github: Authenticated GitHub client + course_id: Course identifier from index.yaml + lab_id: Lab identifier + org: GitHub organization + repo: Repository name (e.g. os-task1-student) + files: File paths from lab config `files:` + local_path: Optional subdirectory in the repo (from moss/plagiarism config) + cache_root: Override cache root directory + github_prefix: Repo name prefix, used to derive student login + + Returns: + CachedSubmission with paths to comparison-ready files, or None + """ + if not files: + logger.warning("No files configured for %s/%s lab %s — nothing to cache", org, repo, lab_id) + return None + + dest_dir = submission_cache_dir(course_id, lab_id, org, repo, cache_root) + dest_dir.mkdir(parents=True, exist_ok=True) + + cached_files: list[Path] = [] + for filename in files: + repo_path = _resolve_repo_file_path(filename, local_path) + content = github.get_file_content(org, repo, repo_path) + if content is None and local_path and repo_path != filename: + # Fallback: try path as listed in files: (matches grading behavior) + content = github.get_file_content(org, repo, filename) + repo_path = filename + + if content is None: + logger.warning("File %s not found in %s/%s — skipping", repo_path, org, repo) + continue + + # Preserve relative path under the repo cache dir (use original filename layout) + local_file = dest_dir / filename + local_file.parent.mkdir(parents=True, exist_ok=True) + local_file.write_bytes(content) + logger.info("Cached %s/%s/%s -> %s", org, repo, repo_path, local_file) + + if local_file.suffix.lower() == ".ipynb": + py_file = convert_ipynb_to_py(local_file) + cached_files.append(py_file) + else: + cached_files.append(local_file) + + if not cached_files: + return None + + student = extract_student_from_repo(repo, github_prefix) if github_prefix else repo + return CachedSubmission( + org=org, + repo=repo, + student=student, + directory=dest_dir, + files=cached_files, + ) + + +def list_cached_submissions( + course_id: str, + lab_id: str, + cache_root: str | Path | None = None, +) -> list[Path]: + """ + List submission directories already present in the cache for a lab. + + Each entry is `{cache_root}/{course_id}/{lab_id}/{org}/{repo}`. + """ + lab_root = get_cache_root(cache_root) / course_id / str(lab_id) + if not lab_root.is_dir(): + return [] + + submissions: list[Path] = [] + for org_dir in sorted(lab_root.iterdir()): + if not org_dir.is_dir(): + continue + for repo_dir in sorted(org_dir.iterdir()): + if repo_dir.is_dir(): + submissions.append(repo_dir) + return submissions diff --git a/grading/plagiarism_check.py b/grading/plagiarism_check.py new file mode 100644 index 0000000..1d02849 --- /dev/null +++ b/grading/plagiarism_check.py @@ -0,0 +1,312 @@ +""" +Background plagiarism check orchestration. + +Called after a successful grade write (`v...`) so the student response +is not blocked by GitHub downloads / compare50. +""" +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any + +from .github_client import GitHubClient +from .plagiarism import ( + filter_matches_above_threshold, + get_plagiarism_config, + compare_submission_against_cache, +) +from .plagiarism_cache import ( + cache_submission_files, + convert_ipynb_to_py, + get_cache_root, + submission_cache_dir, +) +from .plagiarism_store import upsert_matches +from .sheets_comments import notify_teacher_plagiarism + +logger = logging.getLogger(__name__) + + +def _shadow_mode() -> bool: + """ + When true, run detection and store results but do not notify teachers + (no Sheets comments). Used for pilot / shadow rollout. + """ + return os.environ.get("PLAGIARISM_SHADOW_MODE", "").lower() in ("1", "true", "yes") + + +def _db_path_for_cache(cache_root: str | Path | None) -> Path | None: + if cache_root is None: + return None + return Path(get_cache_root(cache_root)) / "plagiarism.db" + + +def _ensure_basefiles( + github: GitHubClient, + course_id: str, + lab_id: str, + basefiles: list[dict[str, str]] | None, + cache_root: str | Path | None = None, +) -> Path | None: + """Download template/base files once into `{cache}/_basefiles/`.""" + if not basefiles: + return None + + base_dir = get_cache_root(cache_root) / course_id / str(lab_id) / "_basefiles" + base_dir.mkdir(parents=True, exist_ok=True) + + any_file = False + for entry in basefiles: + if not isinstance(entry, dict): + continue + repo_full = entry.get("repo", "") + filename = entry.get("filename", "") + if not repo_full or not filename: + continue + parts = repo_full.split("/", 1) + if len(parts) != 2: + logger.warning("Invalid basefile repo '%s' — expected org/repo", repo_full) + continue + org, repo = parts + content = github.get_file_content(org, repo, filename) + if content is None: + logger.warning("Basefile %s/%s not found", repo_full, filename) + continue + dest = base_dir / org / repo / filename + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(content) + if dest.suffix.lower() == ".ipynb": + convert_ipynb_to_py(dest) + any_file = True + logger.info("Cached basefile %s/%s", repo_full, filename) + + return base_dir if any_file else None + + +def _prefill_additional_orgs( + github: GitHubClient, + course_id: str, + lab_id: str, + files: list[str], + github_prefix: str | None, + additional_orgs: list[str] | None, + *, + local_path: str | None = None, + cache_root: str | Path | None = None, + max_repos_per_org: int = 200, +) -> int: + """ + One-time-ish prefill of previous-year orgs into the lab cache. + + Skips repos already present. Returns number of newly cached submissions. + """ + if not additional_orgs or not github_prefix or not files: + return 0 + + cached = 0 + for add_org in additional_orgs: + try: + repos = github.list_repos_with_prefix(add_org, github_prefix) + except Exception: + logger.exception("Failed listing repos in additional org %s", add_org) + continue + + for repo in repos[:max_repos_per_org]: + dest = submission_cache_dir(course_id, lab_id, add_org, repo, cache_root) + if dest.is_dir() and any(dest.iterdir()): + continue + try: + result = cache_submission_files( + github, + course_id, + lab_id, + add_org, + repo, + files, + local_path=local_path, + cache_root=cache_root, + github_prefix=github_prefix, + ) + except Exception: + logger.exception( + "Failed caching additional %s/%s — continuing", add_org, repo + ) + continue + if result is not None: + cached += 1 + + if cached: + logger.info( + "Prefill additional orgs for %s/%s: cached %d new submissions", + course_id, lab_id, cached, + ) + return cached + + +def run_plagiarism_check( + course_id: str, + lab_id: str, + org: str, + repo: str, + lab_config: dict[str, Any], + github_token: str, + *, + cache_root: str | Path | None = None, + # Optional Sheets notification context (from grade_lab) + spreadsheet_id: str | None = None, + worksheet_title: str | None = None, + cell_row: int | None = None, + cell_col: int | None = None, + student: str | None = None, + credentials_file: str | None = None, +) -> list[dict[str, Any]]: + """ + Full plagiarism pipeline for one graded submission: + + 1. Resolve plagiarism/moss config + 2. Prefill additional orgs (previous years) if configured + 3. Cache submission files + 4. Ensure basefiles are cached + 5. Compare new submission vs existing cache + 6. Persist matches above threshold to SQLite + 7. Notify teacher (cell note) unless SHADOW_MODE + + Returns list of flagged match dicts (above threshold). + Never raises — errors are logged (background task must not crash the worker). + """ + try: + cfg = get_plagiarism_config(lab_config) + if cfg is None or not cfg.enabled: + logger.debug("Plagiarism check disabled for %s lab %s", course_id, lab_id) + return [] + + if cfg.engine not in ("compare50", "moss"): + logger.warning( + "Plagiarism engine '%s' not implemented — skipping", cfg.engine + ) + return [] + + files = lab_config.get("files") or [] + if not files: + logger.info("No files: in lab config — skipping plagiarism for %s/%s", course_id, lab_id) + return [] + + github = GitHubClient(github_token) + github_prefix = lab_config.get("github-prefix") + + # Prefill previous years once per lab (marker file under cache) + marker = get_cache_root(cache_root) / course_id / str(lab_id) / "_additional_prefilled" + if cfg.additional and not marker.exists(): + _prefill_additional_orgs( + github, + course_id, + lab_id, + files, + github_prefix, + cfg.additional, + local_path=cfg.local_path, + cache_root=cache_root, + ) + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("ok", encoding="utf-8") + except OSError: + logger.warning("Could not write prefill marker %s", marker) + + cached = cache_submission_files( + github, + course_id, + lab_id, + org, + repo, + files, + local_path=cfg.local_path, + cache_root=cache_root, + github_prefix=github_prefix, + ) + if cached is None: + logger.warning("Failed to cache files for %s/%s — skip plagiarism", org, repo) + return [] + + base_dir = _ensure_basefiles( + github, course_id, lab_id, cfg.basefiles, cache_root=cache_root + ) + + matches = compare_submission_against_cache( + course_id, + lab_id, + org, + repo, + cache_root=cache_root, + github_prefix=github_prefix, + max_matches=cfg.max_matches, + basefiles_dir=base_dir, + write_report=True, + ) + + flagged = filter_matches_above_threshold(matches, cfg.threshold) + upsert_matches( + course_id, + lab_id, + flagged, + db_path=_db_path_for_cache(cache_root), + ) + + flagged_dicts = [ + { + "student_a": m.student_a, + "student_b": m.student_b, + "similarity": m.similarity, + "details": m.details, + } + for m in flagged + ] + + logger.info( + "Plagiarism check for %s/%s lab %s: %d matches, %d above threshold %.2f%s", + org, + repo, + lab_id, + len(matches), + len(flagged), + cfg.threshold, + " [SHADOW]" if _shadow_mode() else "", + ) + + # Teacher notification (skipped in shadow / pilot mode) + if ( + flagged_dicts + and not _shadow_mode() + and spreadsheet_id + and worksheet_title + and cell_row + and cell_col + and credentials_file + ): + graded_student = student or (cached.student if cached else repo) + # Only notify about pairs involving the graded student + relevant = [ + m for m in flagged_dicts + if graded_student in (m["student_a"], m["student_b"]) + ] + if relevant: + notify_teacher_plagiarism( + spreadsheet_id=spreadsheet_id, + worksheet_title=worksheet_title, + row=cell_row, + col=cell_col, + student=graded_student, + lab_short_name=lab_config.get("short-name"), + matches=relevant, + credentials_file=credentials_file, + ) + + return flagged_dicts + except Exception: + logger.exception( + "Plagiarism check failed for %s/%s (course=%s lab=%s)", + org, repo, course_id, lab_id, + ) + return [] diff --git a/grading/plagiarism_store.py b/grading/plagiarism_store.py new file mode 100644 index 0000000..e3f8e06 --- /dev/null +++ b/grading/plagiarism_store.py @@ -0,0 +1,195 @@ +""" +SQLite storage for plagiarism detection matches. +""" +from __future__ import annotations + +import logging +import os +import sqlite3 +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterator + +from .plagiarism import PlagiarismMatch +from .plagiarism_cache import get_cache_root + +logger = logging.getLogger(__name__) + +DEFAULT_DB_NAME = "plagiarism.db" + + +def get_db_path(cache_root: str | Path | None = None) -> Path: + """Return path to plagiarism.db (same volume as the source cache).""" + env_path = os.environ.get("PLAGIARISM_DB_PATH") + if env_path: + return Path(env_path) + return get_cache_root(cache_root) / DEFAULT_DB_NAME + + +@contextmanager +def connect(db_path: str | Path | None = None) -> Iterator[sqlite3.Connection]: + """Open SQLite connection with row factory; ensures schema exists.""" + path = Path(db_path) if db_path else get_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + try: + init_schema(conn) + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_schema(conn: sqlite3.Connection) -> None: + """Create tables if they do not exist.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS plagiarism_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id TEXT NOT NULL, + lab_id TEXT NOT NULL, + student_a TEXT NOT NULL, + student_b TEXT NOT NULL, + similarity REAL NOT NULL, + details TEXT, + checked_at TEXT NOT NULL, + reviewed_by_teacher INTEGER NOT NULL DEFAULT 0, + UNIQUE (course_id, lab_id, student_a, student_b) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_plagiarism_course_lab + ON plagiarism_matches (course_id, lab_id, similarity DESC) + """ + ) + + +@dataclass +class StoredMatch: + id: int + course_id: str + lab_id: str + student_a: str + student_b: str + similarity: float + details: str | None + checked_at: str + reviewed_by_teacher: bool + + +def _ordered_pair(student_a: str, student_b: str) -> tuple[str, str]: + return (student_a, student_b) if student_a <= student_b else (student_b, student_a) + + +def upsert_matches( + course_id: str, + lab_id: str, + matches: list[PlagiarismMatch], + *, + db_path: str | Path | None = None, +) -> int: + """ + Insert or update matches for a course/lab. + + Preserves reviewed_by_teacher=1 on conflict (does not reset teacher review). + Returns number of rows written. + """ + if not matches: + return 0 + + now = datetime.now(timezone.utc).isoformat() + written = 0 + with connect(db_path) as conn: + for match in matches: + a, b = _ordered_pair(match.student_a, match.student_b) + conn.execute( + """ + INSERT INTO plagiarism_matches ( + course_id, lab_id, student_a, student_b, + similarity, details, checked_at, reviewed_by_teacher + ) VALUES (?, ?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(course_id, lab_id, student_a, student_b) DO UPDATE SET + similarity = excluded.similarity, + details = excluded.details, + checked_at = excluded.checked_at + WHERE plagiarism_matches.reviewed_by_teacher = 0 + """, + (course_id, str(lab_id), a, b, match.similarity, match.details or None, now), + ) + written += 1 + return written + + +def list_matches( + course_id: str, + lab_id: str, + *, + min_similarity: float | None = None, + include_reviewed: bool = True, + db_path: str | Path | None = None, +) -> list[StoredMatch]: + """List stored matches for a lab, highest similarity first.""" + clauses = ["course_id = ?", "lab_id = ?"] + params: list[object] = [course_id, str(lab_id)] + + if min_similarity is not None: + clauses.append("similarity >= ?") + params.append(min_similarity) + if not include_reviewed: + clauses.append("reviewed_by_teacher = 0") + + sql = f""" + SELECT id, course_id, lab_id, student_a, student_b, + similarity, details, checked_at, reviewed_by_teacher + FROM plagiarism_matches + WHERE {' AND '.join(clauses)} + ORDER BY similarity DESC, student_a, student_b + """ + with connect(db_path) as conn: + rows = conn.execute(sql, params).fetchall() + + return [ + StoredMatch( + id=row["id"], + course_id=row["course_id"], + lab_id=row["lab_id"], + student_a=row["student_a"], + student_b=row["student_b"], + similarity=row["similarity"], + details=row["details"], + checked_at=row["checked_at"], + reviewed_by_teacher=bool(row["reviewed_by_teacher"]), + ) + for row in rows + ] + + +def mark_reviewed( + course_id: str, + lab_id: str, + student_a: str, + student_b: str, + reviewed: bool = True, + *, + db_path: str | Path | None = None, +) -> bool: + """Set reviewed_by_teacher flag for a pair. Returns True if a row was updated.""" + a, b = _ordered_pair(student_a, student_b) + with connect(db_path) as conn: + cur = conn.execute( + """ + UPDATE plagiarism_matches + SET reviewed_by_teacher = ? + WHERE course_id = ? AND lab_id = ? AND student_a = ? AND student_b = ? + """, + (1 if reviewed else 0, course_id, str(lab_id), a, b), + ) + return cur.rowcount > 0 diff --git a/grading/sheets_comments.py b/grading/sheets_comments.py new file mode 100644 index 0000000..5c741dd --- /dev/null +++ b/grading/sheets_comments.py @@ -0,0 +1,131 @@ +""" +Teacher notifications for plagiarism matches via Google Sheets cell notes. + +Cell-anchored Drive discussion comments cannot be created via public APIs +(see docs/PLAGIARISM_DETECTION_PLAN.md §6). We use gspread notes on the grade +cell so teachers (and Readers) see the flag on hover. Grade value is never changed. +""" +from __future__ import annotations + +import logging +from typing import Any + +import gspread +from oauth2client.service_account import ServiceAccountCredentials + +logger = logging.getLogger(__name__) + +DRIVE_SCOPES = [ + "https://spreadsheets.google.com/feeds", + "https://www.googleapis.com/auth/drive", +] + + +def _credentials(credentials_file: str) -> ServiceAccountCredentials: + return ServiceAccountCredentials.from_json_keyfile_name( + credentials_file, DRIVE_SCOPES + ) + + +def a1_notation(row: int, col: int) -> str: + """Convert 1-based row/col to A1 notation (supports cols beyond Z).""" + n = col + letters = "" + while n: + n, rem = divmod(n - 1, 26) + letters = chr(65 + rem) + letters + return f"{letters}{row}" + + +def format_plagiarism_comment( + *, + lab_short_name: str | None, + student: str, + matches: list[dict[str, Any]], + cell_a1: str | None = None, +) -> str: + """Build teacher-facing Russian note body.""" + lab = lab_short_name or "лабораторная" + lines = [ + f"⚠️ Возможный плагиат ({lab}), студент GitHub: {student}", + ] + if cell_a1: + lines.append(f"Ячейка: {cell_a1}") + for m in matches: + peers = {m["student_a"], m["student_b"]} - {student} + peer = next(iter(peers), m["student_b"]) + pct = round(float(m["similarity"]) * 100) + lines.append(f"• совпадение ~{pct}% с {peer}") + lines.append("Проверьте пару и отметьте review в админ-панели.") + return "\n".join(lines) + + +def set_cell_note( + spreadsheet_id: str, + worksheet_title: str, + row: int, + col: int, + note: str, + *, + credentials_file: str, +) -> bool: + """ + Set / replace a note on a specific cell (visible in Sheets UI on hover). + + Returns True on success. + """ + try: + creds = _credentials(credentials_file) + client = gspread.authorize(creds) + sheet = client.open_by_key(spreadsheet_id).worksheet(worksheet_title) + cell = a1_notation(row, col) + if hasattr(sheet, "update_note"): + sheet.update_note(cell, note) + else: + sheet.insert_note(cell, note) + logger.info("Set note on %s!%s", worksheet_title, cell) + return True + except Exception: + logger.exception( + "Failed to set cell note on %s row=%s col=%s", + spreadsheet_id, row, col, + ) + return False + + +def notify_teacher_plagiarism( + *, + spreadsheet_id: str, + worksheet_title: str, + row: int, + col: int, + student: str, + lab_short_name: str | None, + matches: list[dict[str, Any]], + credentials_file: str, +) -> dict[str, Any]: + """ + Notify teacher about flagged plagiarism for one graded student. + + Writes a cell note only. Returns status dict. + """ + if not matches: + return {"note": False, "cell": None} + + cell = a1_notation(row, col) + content = format_plagiarism_comment( + lab_short_name=lab_short_name, + student=student, + matches=matches, + cell_a1=cell, + ) + + note_ok = set_cell_note( + spreadsheet_id, + worksheet_title, + row, + col, + content, + credentials_file=credentials_file, + ) + return {"note": note_ok, "cell": cell} diff --git a/main.py b/main.py index e5be819..ac43292 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Request, Response, HTTPException +from fastapi import FastAPI, Request, Response, HTTPException, BackgroundTasks from fastapi.staticfiles import StaticFiles import os import yaml @@ -33,6 +33,9 @@ format_grade_with_score, format_score, ) +from grading.plagiarism_check import run_plagiarism_check +from grading.plagiarism import get_plagiarism_config +from grading.plagiarism_store import list_matches, mark_reviewed # Configure logging to both file and console LOG_DIR = os.getenv("LOG_DIR", "logs") @@ -132,7 +135,7 @@ def validate_course_index(): try: index_data = load_course_index() except Exception as e: - print(f"❌ Failed to load course index: {e}") + print(f"[ERROR] Failed to load course index: {e}") return False courses = index_data.get("courses", []) @@ -149,19 +152,19 @@ def validate_course_index(): # Check for missing files missing_files = indexed_files - actual_files if missing_files: - print(f"❌ ERROR: Files referenced in index but not found: {missing_files}") + print(f"[ERROR] Files referenced in index but not found: {missing_files}") return False # Check for orphaned files orphaned_files = actual_files - indexed_files if orphaned_files: - print(f"⚠️ WARNING: Course files not in index (will be ignored): {orphaned_files}") + print(f"[WARN] Course files not in index (will be ignored): {orphaned_files}") # Check for duplicate IDs ids = [entry.get("id") for entry in courses if "id" in entry] if len(ids) != len(set(ids)): duplicates = {x for x in ids if ids.count(x) > 1} - print(f"❌ ERROR: Duplicate course IDs in index: {duplicates}") + print(f"[ERROR] Duplicate course IDs in index: {duplicates}") return False # Validate each indexed file can be loaded @@ -171,13 +174,13 @@ def validate_course_index(): with open(file_path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) if not isinstance(data, dict) or "course" not in data: - print(f"❌ ERROR: Invalid course structure in {entry['file']}") + print(f"[ERROR] Invalid course structure in {entry['file']}") return False except Exception as e: - print(f"❌ ERROR: Failed to load {entry['file']}: {e}") + print(f"[ERROR] Failed to load {entry['file']}: {e}") return False - print(f"✅ Course index validated successfully ({len(courses)} courses)") + print(f"[OK] Course index validated successfully ({len(courses)} courses)") return True def get_course_by_id(course_id: str): @@ -222,9 +225,9 @@ def get_course_by_id(course_id: str): LOGOS_DIR = os.path.join(COURSES_DIR, "logos") if os.path.exists(LOGOS_DIR): app.mount("/courses/logos", StaticFiles(directory=LOGOS_DIR), name="course_logos") - print(f"✅ Course logos available at /courses/logos") + print("[OK] Course logos available at /courses/logos") else: - print(f"⚠️ Warning: Logos directory not found at {LOGOS_DIR}") + print(f"[WARN] Logos directory not found at {LOGOS_DIR}") class AuthRequest(BaseModel): login: str @@ -283,6 +286,104 @@ def logout(request: Request, response: Response): return {"message": "Logged out"} +def require_admin(request: Request) -> str: + """Validate admin_session cookie; return admin login or raise 401.""" + cookie = request.cookies.get("admin_session") + if not cookie: + raise HTTPException(status_code=401, detail="Нет сессии") + try: + login = signer.unsign(cookie, max_age=3600).decode() + except BadSignature: + raise HTTPException(status_code=401, detail="Невалидная или просроченная сессия") + if login != ADMIN_LOGIN: + raise HTTPException(status_code=401, detail="Невалидная сессия") + return login + + +@app.get("/courses/{course_id}/labs/{lab_id}/plagiarism") +@limiter.limit("30/minute") +def get_plagiarism_matches( + request: Request, + course_id: str, + lab_id: str, + min_similarity: float | None = None, + include_reviewed: bool = True, +): + """ + List plagiarism matches for a lab (admin only). + + Returns pairs above the configured / requested similarity threshold. + """ + require_admin(request) + + course_info = get_course_by_id(course_id) + lab_number = parse_lab_id(lab_id) + lab_config = course_info.get("labs", {}).get(str(lab_number), {}) + cfg = get_plagiarism_config(lab_config) + + threshold = min_similarity + if threshold is None and cfg is not None: + threshold = cfg.threshold + + matches = list_matches( + course_id, + str(lab_number), + min_similarity=threshold, + include_reviewed=include_reviewed, + ) + return { + "course_id": course_id, + "lab_id": str(lab_number), + "threshold": threshold, + "matches": [ + { + "student_a": m.student_a, + "student_b": m.student_b, + "similarity": m.similarity, + "details": m.details, + "checked_at": m.checked_at, + "reviewed_by_teacher": m.reviewed_by_teacher, + } + for m in matches + ], + } + + +class PlagiarismReviewRequest(BaseModel): + student_a: str = Field(..., min_length=1) + student_b: str = Field(..., min_length=1) + reviewed: bool = True + + +@app.post("/courses/{course_id}/labs/{lab_id}/plagiarism/review") +@limiter.limit("30/minute") +def review_plagiarism_match( + request: Request, + course_id: str, + lab_id: str, + body: PlagiarismReviewRequest, +): + """Mark a plagiarism pair as reviewed (or clear the flag). Admin only.""" + require_admin(request) + lab_number = parse_lab_id(lab_id) + updated = mark_reviewed( + course_id, + str(lab_number), + body.student_a, + body.student_b, + reviewed=body.reviewed, + ) + if not updated: + raise HTTPException(status_code=404, detail="Пара не найдена") + return { + "course_id": course_id, + "lab_id": str(lab_number), + "student_a": body.student_a, + "student_b": body.student_b, + "reviewed_by_teacher": body.reviewed, + } + + @app.get("/courses") @limiter.limit("100/minute") def get_courses(request: Request, status: str = "active"): @@ -349,6 +450,19 @@ def parse_lab_id(lab_id: str) -> int: def get_course(request: Request, course_id: str): course_info = get_course_by_id(course_id) + labs_summary = [] + for lab_key, lab_cfg in (course_info.get("labs") or {}).items(): + if not isinstance(lab_cfg, dict): + continue + labs_summary.append({ + "id": str(lab_key), + "short-name": lab_cfg.get("short-name") or f"ЛР{lab_key}", + "github-prefix": lab_cfg.get("github-prefix"), + "has_plagiarism": bool( + lab_cfg.get("plagiarism") is not None or lab_cfg.get("moss") is not None + ), + }) + return { "id": course_id, "config": course_info["_meta"]["filename"], @@ -360,6 +474,7 @@ def get_course(request: Request, course_id: str): "google-spreadsheet": course_info.get("google", {}).get("spreadsheet", "Unknown"), "status": course_info["_meta"]["status"], "priority": course_info["_meta"]["priority"], + "labs": labs_summary, } @app.delete("/courses/{course_id}") @@ -592,7 +707,14 @@ class GradeRequest(BaseModel): @app.post("/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade") @limiter.limit("10/minute") -def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grade_request: GradeRequest): +def grade_lab( + request: Request, + course_id: str, + group_id: str, + lab_id: str, + grade_request: GradeRequest, + background_tasks: BackgroundTasks, +): """ Grade a lab submission by checking GitHub repository and CI status. @@ -604,6 +726,7 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad 2. CI evaluation 3. Return early for errors/pending (no Sheets connection needed) 4. Connect to Sheets only when we have a result to write + 5. On successful `v...` grade, schedule background plagiarism check """ logger.info(f"Grading attempt - Course: {course_id}, Group: {group_id}, Lab: {lab_id}, GitHub: {grade_request.github}") @@ -809,6 +932,28 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad sheet.update_cell(row_idx, lab_col, final_result) logger.info(f"Successfully updated grade for '{username}' in lab {lab_id}") + # Schedule plagiarism check after successful pass (does not block student response) + if str(final_result).startswith("v") and get_plagiarism_config(lab_config_dict): + background_tasks.add_task( + run_plagiarism_check, + course_id, + str(lab_number), + org, + repo_name, + lab_config_dict, + GITHUB_TOKEN, + spreadsheet_id=spreadsheet_id, + worksheet_title=group_id, + cell_row=row_idx, + cell_col=lab_col, + student=username, + credentials_file=CREDENTIALS_FILE, + ) + logger.info( + "Scheduled background plagiarism check for %s/%s (lab %s)", + org, repo_name, lab_id, + ) + response = { "status": "updated", "result": final_result, diff --git a/requirements.txt b/requirements.txt index 2f50d77..856b1ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ requests python-multipart python-dotenv itsdangerous -slowapi \ No newline at end of file +slowapi +compare50>=1.2.0 diff --git a/scripts/README.md b/scripts/README.md index b6d6cb4..5b2cb4d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,32 @@ This directory contains scripts for managing production deployments. +## plagiarism_batch_course.py + +Прогон антиплагиата по **всем лабам курса** (скачать репо орг → compare50 → SQLite). +Таблицу и `v` не трогает. + +```bash +python scripts/plagiarism_batch_course.py --course os-2025-spring --skip-additional +python scripts/plagiarism_batch_course.py --course os-2025-spring --labs 2 5 --max-repos 20 +``` + +## plagiarism_local_demo.py + +Local smoke-test for plagiarism detection **without** Google Sheets and +without an organization admin token. + +```bash +# 1) Pure offline demo (synthetic identical / different code) +python scripts/plagiarism_local_demo.py + +# 2) Your readable GitHub repos (personal GITHUB_TOKEN is enough) +set GITHUB_TOKEN=ghp_... +python scripts/plagiarism_local_demo.py repos --repos you/lab-alice you/lab-bob --files main.py --prefix lab +``` + +Creates `./plagiarism_cache/` and `plagiarism.db`. Does not write to Google. + ## switch-branch.sh Utility script for switching between Git branches in production. diff --git a/scripts/cleanup_experiment_comments.py b/scripts/cleanup_experiment_comments.py new file mode 100644 index 0000000..19620f9 --- /dev/null +++ b/scripts/cleanup_experiment_comments.py @@ -0,0 +1,38 @@ +"""Delete experimental Drive comments created by experiment_sheet_cell_comment.py.""" +import os + +import requests +from dotenv import load_dotenv +from oauth2client.service_account import ServiceAccountCredentials + +load_dotenv() +CRED = os.getenv("CREDENTIALS_FILE", "credentials.json") +SHEET_ID = "1t2sAxKgXqS4yB7fTM_dj6JKOcW7kGjtmHAQXbdBWQC0" +creds = ServiceAccountCredentials.from_json_keyfile_name( + CRED, + [ + "https://spreadsheets.google.com/feeds", + "https://www.googleapis.com/auth/drive", + ], +) +token = creds.get_access_token().access_token +headers = {"Authorization": f"Bearer {token}"} +r = requests.get( + f"https://www.googleapis.com/drive/v3/files/{SHEET_ID}/comments", + headers=headers, + params={"fields": "comments(id,content)", "pageSize": 100}, + timeout=30, +) +comments = r.json().get("comments", []) +deleted = 0 +for c in comments: + content = c.get("content") or "" + if "plagiarism-anchor-experiment" in content: + dr = requests.delete( + f"https://www.googleapis.com/drive/v3/files/{SHEET_ID}/comments/{c['id']}", + headers=headers, + timeout=30, + ) + print("delete", c["id"], dr.status_code) + deleted += 1 +print("deleted", deleted, "of", len(comments)) diff --git a/scripts/compare_engines_jplag_compare50.py b/scripts/compare_engines_jplag_compare50.py new file mode 100644 index 0000000..59ce8e4 --- /dev/null +++ b/scripts/compare_engines_jplag_compare50.py @@ -0,0 +1,395 @@ +""" +Compare compare50 vs JPlag on a cached lab corpus (Phase 0 engine choice). + +Usage: + python scripts/compare_engines_jplag_compare50.py --course os-2025-spring --lab 2 + python scripts/compare_engines_jplag_compare50.py --course os-2025-spring --lab 3 --top 30 + +Requires: Java 21+, tools/jplag.jar (download from JPlag releases), compare50 installed. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grading.plagiarism import run_compare50 as run_compare50_engine # noqa: E402 +from grading.plagiarism_cache import ( # noqa: E402 + extract_student_from_repo, + get_cache_root, + list_cached_submissions, +) + +DEFAULT_JPLAG_JAR = ROOT / "tools" / "jplag.jar" + + +def list_real_submissions(course_id: str, lab_id: str) -> list[Path]: + """Cached submission dirs, skipping internal `_reports` / markers.""" + return [ + d + for d in list_cached_submissions(course_id, lab_id) + if not d.parent.name.startswith("_") and not d.name.startswith("_") + ] + + +def _github_prefix_for_lab(course_id: str, lab_id: str) -> str | None: + """Best-effort: infer prefix from first cached repo name (os-task2-Student).""" + subs = list_real_submissions(course_id, lab_id) + if not subs: + return None + name = subs[0].name + if "-" not in name: + return None + return name.rsplit("-", 1)[0] + + +def _student_label(repo_dir: Path, github_prefix: str | None) -> str: + return extract_student_from_repo(repo_dir.name, github_prefix) + + +def prepare_jplag_root( + course_id: str, + lab_id: str, + *, + work_dir: Path, + github_prefix: str | None, +) -> tuple[Path, int]: + """ + Build JPlag input: one subdirectory per student with source files. + + Returns (submissions_root, n_students). + """ + if work_dir.exists(): + shutil.rmtree(work_dir) + subs_root = work_dir / "submissions" + subs_root.mkdir(parents=True) + + n = 0 + for src_dir in list_real_submissions(course_id, lab_id): + student = _student_label(src_dir, github_prefix) + # Prefer unique key if student collision across orgs + org = src_dir.parent.name + key = f"{org}__{student}" + dst = subs_root / key + dst.mkdir(parents=True, exist_ok=True) + copied = 0 + for f in src_dir.rglob("*"): + if not f.is_file(): + continue + if f.suffix.lower() not in { + ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx", + ".py", ".java", ".js", ".ts", ".sh", + }: + continue + # Flatten into student dir (JPlag reads all files under submission) + target = dst / f.name + if target.exists(): + target = dst / f"{f.parent.name}_{f.name}" + shutil.copy2(f, target) + copied += 1 + if copied: + n += 1 + else: + shutil.rmtree(dst, ignore_errors=True) + return subs_root, n + + +def run_jplag( + submissions_root: Path, + *, + language: str, + jar: Path, + result_stem: Path, + min_similarity: float = 0.0, +) -> list[tuple[str, str, float]]: + """ + Run JPlag in RUN mode; parse overview.json from the .jplag zip. + + Returns list of (student_a, student_b, similarity 0..1) sorted desc. + """ + result_stem.parent.mkdir(parents=True, exist_ok=True) + # Remove previous result + for p in ( + Path(str(result_stem) + ".jplag"), + result_stem.with_suffix(".jplag"), + ): + if p.exists(): + p.unlink() + + cmd = [ + "java", + "-jar", + str(jar), + "-l", + language, + "-M", + "RUN", + "-n", + "-1", + "-r", + str(result_stem), + str(submissions_root), + ] + if language == "cpp": + # insert --normalize before the submissions path + cmd.insert(-1, "--normalize") + + print("Running:", " ".join(cmd)) + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") + if proc.returncode != 0: + print("JPlag stdout:\n", proc.stdout[-2000:]) + print("JPlag stderr:\n", proc.stderr[-2000:]) + raise RuntimeError(f"JPlag failed with code {proc.returncode}") + + jplag_file = Path(str(result_stem) + ".jplag") + if not jplag_file.exists(): + # newer versions may write differently + candidates = list(result_stem.parent.glob("*.jplag")) + if not candidates: + raise FileNotFoundError(f"No .jplag result near {result_stem}") + jplag_file = max(candidates, key=lambda p: p.stat().st_mtime) + + matches: list[tuple[str, str, float]] = [] + with zipfile.ZipFile(jplag_file) as zf: + names = zf.namelist() + # JPlag 6.2+: topComparisons.json; older: overview.json + top_name = next( + ( + n + for n in ( + "topComparisons.json", + "overview.json", + ) + if n in names + ), + None, + ) + if top_name is None: + top_name = next((n for n in names if n.endswith("topComparisons.json")), None) + if top_name is None: + top_name = next((n for n in names if n.endswith("overview.json")), None) + if top_name is None: + raise FileNotFoundError( + f"topComparisons/overview missing in {jplag_file}: " + f"{[n for n in names if not n.startswith('files/') and not n.startswith('comparisons/')][:30]}" + ) + payload = json.loads(zf.read(top_name)) + + if isinstance(payload, list): + comparisons = payload + else: + comparisons = ( + payload.get("top_comparisons") + or payload.get("topComparisons") + or payload.get("comparisons") + or [] + ) + for c in comparisons: + a = ( + c.get("first_submission") + or c.get("submissionName1") + or c.get("firstSubmission") + or "" + ) + b = ( + c.get("second_submission") + or c.get("submissionName2") + or c.get("secondSubmission") + or "" + ) + sim = c.get("similarity") + if sim is None: + sims = c.get("similarities") or {} + sim = sims.get("AVG", sims.get("MAX")) + if not a or not b or sim is None: + continue + sim_f = float(sim) + if sim_f > 1.0: + sim_f /= 100.0 + if sim_f < min_similarity: + continue + a_s = a.split("__", 1)[-1] + b_s = b.split("__", 1)[-1] + if a_s > b_s: + a_s, b_s = b_s, a_s + matches.append((a_s, b_s, sim_f)) + + matches.sort(key=lambda t: t[2], reverse=True) + return matches + + +def run_compare50( + course_id: str, + lab_id: str, + *, + github_prefix: str | None, + max_matches: int, +) -> list[tuple[str, str, float]]: + """Full pairwise corpus compare (same setting as JPlag batch).""" + dirs = list_real_submissions(course_id, lab_id) + results = run_compare50_engine( + dirs, + github_prefix=github_prefix, + max_matches=max_matches, + output_dir=None, + ) + out = [(m.student_a, m.student_b, float(m.similarity)) for m in results] + out.sort(key=lambda t: t[2], reverse=True) + return out + + +def pair_key(a: str, b: str) -> tuple[str, str]: + return (a, b) if a <= b else (b, a) + + +def analyze( + c50: list[tuple[str, str, float]], + jpl: list[tuple[str, str, float]], + *, + top: int, + threshold: float, +) -> dict: + c50_top = c50[:top] + jpl_top = jpl[:top] + c50_set = {pair_key(a, b) for a, b, _ in c50_top} + jpl_set = {pair_key(a, b) for a, b, _ in jpl_top} + overlap = c50_set & jpl_set + + c50_flag = {pair_key(a, b) for a, b, s in c50 if s >= threshold} + jpl_flag = {pair_key(a, b) for a, b, s in jpl if s >= threshold} + + # Spearman-like: for shared pairs, compare ranks + c50_rank = {pair_key(a, b): i for i, (a, b, _) in enumerate(c50_top)} + jpl_rank = {pair_key(a, b): i for i, (a, b, _) in enumerate(jpl_top)} + shared_ranks = [] + for p in overlap: + shared_ranks.append((c50_rank[p], jpl_rank[p])) + + return { + "compare50_top": c50_top, + "jplag_top": jpl_top, + "top_overlap_count": len(overlap), + "top_overlap_pairs": sorted(overlap), + "top_only_compare50": sorted(c50_set - jpl_set), + "top_only_jplag": sorted(jpl_set - c50_set), + "flagged_compare50": len(c50_flag), + "flagged_jplag": len(jpl_flag), + "flagged_overlap": len(c50_flag & jpl_flag), + "flagged_only_compare50": sorted(c50_flag - jpl_flag)[:20], + "flagged_only_jplag": sorted(jpl_flag - c50_flag)[:20], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--course", required=True) + parser.add_argument("--lab", required=True) + parser.add_argument("--language", default="cpp", help="JPlag language (cpp, python3, ...)") + parser.add_argument("--top", type=int, default=25) + parser.add_argument("--threshold", type=float, default=0.6) + parser.add_argument("--max-matches", type=int, default=200) + parser.add_argument("--jplag-jar", type=Path, default=DEFAULT_JPLAG_JAR) + parser.add_argument( + "--work-dir", + type=Path, + default=None, + help="Scratch dir (default: plagiarism_cache/_engine_compare//)", + ) + args = parser.parse_args() + + if not args.jplag_jar.is_file(): + raise SystemExit(f"JPlag jar not found: {args.jplag_jar}") + + github_prefix = _github_prefix_for_lab(args.course, args.lab) + print(f"course={args.course} lab={args.lab} prefix={github_prefix}") + + work = args.work_dir or ( + get_cache_root() / "_engine_compare" / args.course / str(args.lab) + ) + subs_root, n = prepare_jplag_root( + args.course, args.lab, work_dir=work, github_prefix=github_prefix + ) + print(f"Prepared {n} JPlag submissions under {subs_root}") + if n < 2: + raise SystemExit("Need at least 2 submissions in cache") + + print("\n=== compare50 ===") + c50 = run_compare50( + args.course, + args.lab, + github_prefix=github_prefix, + max_matches=args.max_matches, + ) + print(f"compare50 pairs: {len(c50)}") + for a, b, s in c50[:10]: + print(f" {s:6.3f} {a} ↔ {b}") + + print("\n=== JPlag ===") + jpl = run_jplag( + subs_root, + language=args.language, + jar=args.jplag_jar, + result_stem=work / "jplag_results", + ) + print(f"JPlag pairs: {len(jpl)}") + for a, b, s in jpl[:10]: + print(f" {s:6.3f} {a} ↔ {b}") + + stats = analyze(c50, jpl, top=args.top, threshold=args.threshold) + print(f"\n=== Overlap top-{args.top} ===") + print(f"shared pairs: {stats['top_overlap_count']} / {args.top}") + print(f"only compare50: {len(stats['top_only_compare50'])}") + print(f"only JPlag: {len(stats['top_only_jplag'])}") + print(f"\n=== Flagged @ threshold {args.threshold} ===") + print(f"compare50: {stats['flagged_compare50']}") + print(f"JPlag: {stats['flagged_jplag']}") + print(f"overlap: {stats['flagged_overlap']}") + if stats["flagged_only_compare50"]: + print("flagged only by compare50 (sample):", stats["flagged_only_compare50"][:8]) + if stats["flagged_only_jplag"]: + print("flagged only by JPlag (sample):", stats["flagged_only_jplag"][:8]) + + out_json = work / "comparison_summary.json" + payload = { + "course": args.course, + "lab": args.lab, + "language": args.language, + "n_submissions": n, + "threshold": args.threshold, + "top": args.top, + "compare50_top": [ + {"a": a, "b": b, "similarity": s} for a, b, s in stats["compare50_top"] + ], + "jplag_top": [ + {"a": a, "b": b, "similarity": s} for a, b, s in stats["jplag_top"] + ], + "stats": { + k: v + for k, v in stats.items() + if k not in ("compare50_top", "jplag_top") + }, + } + # make sets JSON-serializable + for key in ( + "top_overlap_pairs", + "top_only_compare50", + "top_only_jplag", + "flagged_only_compare50", + "flagged_only_jplag", + ): + payload["stats"][key] = [list(p) for p in payload["stats"][key]] + + out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nWrote {out_json}") + + +if __name__ == "__main__": + main() diff --git a/scripts/experiment_sheet_cell_comment.py b/scripts/experiment_sheet_cell_comment.py new file mode 100644 index 0000000..b11ef22 --- /dev/null +++ b/scripts/experiment_sheet_cell_comment.py @@ -0,0 +1,213 @@ +"""Experiment: can we create a cell-anchored comment on Google Sheets? + +Tries several Drive API anchor formats on the course spreadsheet (cell T50 +of sheet 4333K), then an XLSX round-trip via openpyxl on a NEW converted file. +""" +from __future__ import annotations + +import io +import json +import os +from datetime import datetime, timezone + +import gspread +import requests +from dotenv import load_dotenv +from oauth2client.service_account import ServiceAccountCredentials + +load_dotenv() + +CRED = os.getenv("CREDENTIALS_FILE", "credentials.json") +SHEET_ID = "1t2sAxKgXqS4yB7fTM_dj6JKOcW7kGjtmHAQXbdBWQC0" +WS_TITLE = "4333K" +TEST_ROW, TEST_COL = 50, 20 # T50 — far from grade cells +MARKER = f"[plagiarism-anchor-experiment {datetime.now(timezone.utc).isoformat()}]" + +SCOPES = [ + "https://spreadsheets.google.com/feeds", + "https://www.googleapis.com/auth/drive", +] + + +def main() -> None: + creds = ServiceAccountCredentials.from_json_keyfile_name(CRED, SCOPES) + token = creds.get_access_token().access_token + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + gc = gspread.authorize(creds) + ss = gc.open_by_key(SHEET_ID) + ws = ss.worksheet(WS_TITLE) + sheet_gid = ws.id + print(f"worksheet={WS_TITLE!r} gid={sheet_gid} test_cell=T50") + + list_url = f"https://www.googleapis.com/drive/v3/files/{SHEET_ID}/comments" + r = requests.get( + list_url, + headers=headers, + params={ + "fields": "comments(id,content,anchor,quotedFileContent,createdTime)", + "pageSize": 20, + }, + timeout=30, + ) + print("LIST status", r.status_code) + comments = r.json().get("comments", []) if r.ok else [] + print(f"existing comments: {len(comments)}") + for c in comments[:8]: + content = (c.get("content") or "")[:80].encode("ascii", "replace").decode() + print( + " id=", + c.get("id"), + "anchor=", + c.get("anchor"), + "content=", + content, + ) + + # Match the format already stored on this spreadsheet by our notifier, + # plus variants with real sheet gid / proprietary range id style. + anchors = { + "none": None, + "like_existing_tab0": json.dumps( + { + "type": "workbook-range", + "sheetsTabId": 0, + "startRowIndex": TEST_ROW - 1, + "endRowIndex": TEST_ROW, + "startColumnIndex": TEST_COL - 1, + "endColumnIndex": TEST_COL, + } + ), + "like_existing_real_gid": json.dumps( + { + "type": "workbook-range", + "sheetsTabId": sheet_gid, + "startRowIndex": TEST_ROW - 1, + "endRowIndex": TEST_ROW, + "startColumnIndex": TEST_COL - 1, + "endColumnIndex": TEST_COL, + } + ), + "proprietary_range_id": json.dumps( + { + "type": "workbook-range", + "uid": 0, + "range": "999000111", + } + ), + "context_html": "__CONTEXT__", + } + + create_url = f"https://www.googleapis.com/drive/v3/files/{SHEET_ID}/comments" + for name, anchor in anchors.items(): + body: dict = {"content": f"{MARKER} variant={name}"} + if name == "context_html": + body["context"] = { + "type": "text/html", + "value": f"cell T50 sheet {WS_TITLE}", + } + elif anchor is not None: + body["anchor"] = anchor + resp = requests.post( + create_url, + headers=headers, + params={"fields": "id,content,anchor,htmlContent,quotedFileContent"}, + json=body, + timeout=30, + ) + created = resp.json() if resp.ok else {"error": resp.text[:300]} + print( + f"CREATE {name}: HTTP {resp.status_code} " + f"id={created.get('id')} anchor={created.get('anchor')!r}" + ) + + try: + from openpyxl import load_workbook + from openpyxl.comments import Comment as XLComment + except ImportError: + print("openpyxl not installed — skipping xlsx experiment") + print("DONE") + return + + export_url = f"https://www.googleapis.com/drive/v3/files/{SHEET_ID}/export" + er = requests.get( + export_url, + headers={"Authorization": f"Bearer {token}"}, + params={ + "mimeType": ( + "application/vnd.openxmlformats-officedocument." + "spreadsheetml.sheet" + ) + }, + timeout=120, + ) + print("EXPORT xlsx", er.status_code, "bytes", len(er.content) if er.ok else er.text[:200]) + if not er.ok: + print("DONE") + return + + bio = io.BytesIO(er.content) + wb = load_workbook(bio) + if WS_TITLE not in wb.sheetnames: + print("sheet missing in export:", wb.sheetnames[:10]) + print("DONE") + return + + xws = wb[WS_TITLE] + cell = xws.cell(row=TEST_ROW, column=TEST_COL) + cell.value = cell.value or "anchor-test" + cell.comment = XLComment(f"{MARKER} xlsx-comment", "lab-grader-bot") + out = io.BytesIO() + wb.save(out) + out.seek(0) + + meta = { + "name": f"plagiarism-anchor-xlsx-test-{datetime.now().strftime('%H%M%S')}", + "mimeType": "application/vnd.google-apps.spreadsheet", + } + files = { + "data": ("metadata", json.dumps(meta), "application/json; charset=UTF-8"), + "file": ( + "book.xlsx", + out.getvalue(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + } + up = requests.post( + "https://www.googleapis.com/upload/drive/v3/files" + "?uploadType=multipart&fields=id,name,webViewLink", + headers={"Authorization": f"Bearer {token}"}, + files=files, + timeout=180, + ) + print("UPLOAD converted", up.status_code, up.text[:500]) + if not up.ok: + print("DONE") + return + + new_id = up.json()["id"] + print("NEW FILE link=", up.json().get("webViewLink")) + lr = requests.get( + f"https://www.googleapis.com/drive/v3/files/{new_id}/comments", + headers=headers, + params={"fields": "comments(id,content,anchor)", "pageSize": 50}, + timeout=30, + ) + print("NEW FILE Drive comments:", lr.status_code, json.dumps(lr.json(), ensure_ascii=False)[:1000]) + + try: + nss = gc.open_by_key(new_id) + nws = nss.worksheet(WS_TITLE) + note = nws.get_note("T50") if hasattr(nws, "get_note") else None + print("NEW FILE T50 note=", repr(note)) + except Exception as e: + print("gspread check failed", e) + + print("DONE") + + +if __name__ == "__main__": + main() diff --git a/scripts/grade_student_labs.py b/scripts/grade_student_labs.py new file mode 100644 index 0000000..8f12489 --- /dev/null +++ b/scripts/grade_student_labs.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Grade all labs for one student via the real grade_lab endpoint logic. + +Writes v/x to Google Sheets when checks pass; schedules plagiarism BackgroundTasks. + +Example: + python scripts/grade_student_labs.py --course os-2025-spring --group 4333K --github dorofeevalexru + python scripts/grade_student_labs.py --course os-2025-spring --group 4333K --github dorofeevalexru --labs 2 5 +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import yaml +from fastapi import BackgroundTasks +from starlette.requests import Request + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +try: + from dotenv import load_dotenv + load_dotenv(ROOT / ".env") +except ImportError: + pass + +# Import app pieces after env is loaded +from main import GradeRequest, get_course_by_id, grade_lab # noqa: E402 + + +def _fake_request() -> Request: + scope = { + "type": "http", + "method": "POST", + "path": "/grade", + "headers": [], + "client": ("127.0.0.1", 12345), + } + + async def receive(): + return {"type": "http.request"} + + request = Request(scope, receive) + if not hasattr(request.state, "view_rate_limit"): + request.state.view_rate_limit = None + return request + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--course", required=True, help="Course id from index.yaml") + parser.add_argument("--group", required=True, help="Worksheet title, e.g. 4333K") + parser.add_argument("--github", required=True, help="Student GitHub login") + parser.add_argument("--labs", nargs="*", help="Lab ids to grade (default: all with files:)") + args = parser.parse_args() + + for key in ("GITHUB_TOKEN", "ADMIN_LOGIN", "ADMIN_PASSWORD"): + if not os.environ.get(key): + print(f"Missing {key} in environment / .env") + return 1 + + course = get_course_by_id(args.course) + labs = course.get("labs") or {} + lab_ids = args.labs if args.labs else [ + lid for lid, cfg in labs.items() + if isinstance(cfg, dict) and cfg.get("files") + ] + + print(f"Course={args.course} group={args.group} github={args.github}") + print(f"Labs: {lab_ids}") + print(f"PLAGIARISM_SHADOW_MODE={os.environ.get('PLAGIARISM_SHADOW_MODE')}") + + request = _fake_request() + grade_request = GradeRequest(github=args.github) + background_tasks = BackgroundTasks() + + results = [] + for lab_id in lab_ids: + cfg = labs.get(str(lab_id)) or labs.get(lab_id) or {} + short = cfg.get("short-name") or f"ЛР{lab_id}" + # grade_lab accepts lab_id like ЛР5 or numeric; use short-name like UI + lab_path_id = short + print(f"\n=== Grading {lab_path_id} (id={lab_id}) ===") + try: + result = grade_lab( + request, + args.course, + args.group, + lab_path_id, + grade_request, + background_tasks, + ) + print(result) + results.append((lab_path_id, result)) + except Exception as exc: + detail = getattr(exc, "detail", str(exc)) + print(f"FAIL: {detail}") + results.append((lab_path_id, {"status": "error", "detail": detail})) + + # Run plagiarism / other background tasks now (same as after HTTP response) + print("\n=== Running background tasks (plagiarism etc.) ===") + import asyncio + + async def _run_bg(): + await background_tasks() + + asyncio.run(_run_bg()) + + print("\n=== Summary ===") + for name, res in results: + if isinstance(res, dict): + print(f"{name}: status={res.get('status')} result={res.get('result')} msg={res.get('message') or res.get('detail')}") + else: + print(f"{name}: {res}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/menu.py b/scripts/menu.py new file mode 100644 index 0000000..89cbd20 --- /dev/null +++ b/scripts/menu.py @@ -0,0 +1,630 @@ +#!/usr/bin/env python3 +""" +Интерактивное консольное меню для типичных задач lab-grader. + +Запуск из корня репозитория: + + python scripts/menu.py + +Сохраняет последние course/group в .labgrader_cli.json (в gitignore). +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +import webbrowser +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +STATE_PATH = ROOT / ".labgrader_cli.json" +SCRIPTS = ROOT / "scripts" +FRONTEND_DIR = ROOT / "frontend" / "courses-front" +LOG_DIR = ROOT / "logs" +BACKEND_PORT = 8000 +FRONTEND_PORT_START = 5173 +FRONTEND_PORT_END = 5190 + +sys.path.insert(0, str(ROOT)) + +try: + from dotenv import load_dotenv + + load_dotenv(ROOT / ".env") +except ImportError: + pass + + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + +def clear_hint() -> None: + print() + + +def pause() -> None: + input("\n[Enter] вернуться в меню… ") + + +def ask(prompt: str, default: str | None = None) -> str: + if default: + raw = input(f"{prompt} [{default}]: ").strip() + return raw or default + while True: + raw = input(f"{prompt}: ").strip() + if raw: + return raw + print(" Нужно значение.") + + +def ask_optional(prompt: str, default: str = "") -> str: + raw = input(f"{prompt} [{default}]: ").strip() if default else input(f"{prompt} (пусто = пропуск): ").strip() + return raw if raw else default + + +def ask_yes_no(prompt: str, default: bool = True) -> bool: + hint = "Y/n" if default else "y/N" + raw = input(f"{prompt} [{hint}]: ").strip().lower() + if not raw: + return default + return raw in ("y", "yes", "д", "да", "1") + + +def pick_from_list(title: str, items: list[tuple[str, str]], *, allow_custom: bool = False) -> str | None: + """ + items: list of (id, label). Returns chosen id or None if cancelled. + """ + if not items and not allow_custom: + print(" Список пуст.") + return None + print(f"\n{title}") + for i, (item_id, label) in enumerate(items, 1): + print(f" {i}) {item_id} — {label}") + if allow_custom: + print(" c) ввести вручную") + print(" 0) отмена") + raw = input("Выбор: ").strip().lower() + if raw in ("", "0"): + return None + if allow_custom and raw == "c": + return ask("Введите значение") + if raw.isdigit() and 1 <= int(raw) <= len(items): + return items[int(raw) - 1][0] + print(" Неверный выбор.") + return None + + +# --------------------------------------------------------------------------- +# State / course loading +# --------------------------------------------------------------------------- + +def load_state() -> dict: + if STATE_PATH.is_file(): + try: + return json.loads(STATE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return {} + + +def save_state(state: dict) -> None: + try: + STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + except OSError as exc: + print(f" (не удалось сохранить состояние: {exc})") + + +def list_courses() -> list[tuple[str, str]]: + index_path = ROOT / "courses" / "index.yaml" + with open(index_path, encoding="utf-8") as f: + index = yaml.safe_load(f) or {} + out: list[tuple[str, str]] = [] + for item in index.get("courses", []): + cid = item.get("id") + if not cid: + continue + status = item.get("status", "?") + out.append((cid, f"{item.get('file', '')} [{status}]")) + return out + + +def load_course_config(course_id: str) -> dict: + index_path = ROOT / "courses" / "index.yaml" + with open(index_path, encoding="utf-8") as f: + index = yaml.safe_load(f) or {} + entry = next((c for c in index.get("courses", []) if c.get("id") == course_id), None) + if not entry: + raise SystemExit(f"Курс '{course_id}' не найден в index.yaml") + path = ROOT / "courses" / entry["file"] + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) + return data["course"] + + +def lab_choices(course: dict) -> list[tuple[str, str]]: + labs = course.get("labs") or {} + items: list[tuple[str, str]] = [] + for lid, cfg in labs.items(): + if not isinstance(cfg, dict): + continue + short = cfg.get("short-name") or f"ЛР{lid}" + plag = "plagiarism" if (cfg.get("plagiarism") is not None or cfg.get("moss") is not None) else "—" + files = ",".join(cfg.get("files") or []) or "—" + items.append((str(lid), f"{short} files={files} {plag}")) + return items + + +def pick_course(state: dict) -> str | None: + courses = list_courses() + default = state.get("course_id") + # Put default first if present + if default: + courses = sorted(courses, key=lambda x: 0 if x[0] == default else 1) + chosen = pick_from_list("Курс:", courses, allow_custom=True) + if chosen: + state["course_id"] = chosen + save_state(state) + return chosen + + +def pick_labs(course: dict) -> list[str] | None: + items = lab_choices(course) + print("\nЛабораторные:") + for i, (lid, label) in enumerate(items, 1): + print(f" {i}) {lid} — {label}") + print(" a) все с files:") + print(" 0) отмена") + raw = input("Номера через пробел (например: 0 1 2 3) или a: ").strip().lower() + if raw in ("", "0"): + return None + if raw == "a": + return [ + lid + for lid, cfg in (course.get("labs") or {}).items() + if isinstance(cfg, dict) and cfg.get("files") + ] + # support both indices and lab ids + chosen: list[str] = [] + id_by_index = {str(i): lid for i, (lid, _) in enumerate(items, 1)} + valid_ids = {lid for lid, _ in items} + for tok in raw.replace(",", " ").split(): + if tok in id_by_index: + chosen.append(id_by_index[tok]) + elif tok in valid_ids: + chosen.append(tok) + else: + print(f" Пропуск неизвестного: {tok}") + return chosen or None + + +def run_script(script_name: str, args: list[str]) -> int: + cmd = [sys.executable, str(SCRIPTS / script_name), *args] + print("\n→", " ".join(cmd)) + print("-" * 60) + result = subprocess.run(cmd, cwd=str(ROOT)) + print("-" * 60) + print(f"Код выхода: {result.returncode}") + return result.returncode + + +def env_status() -> None: + keys = [ + "GITHUB_TOKEN", + "ADMIN_LOGIN", + "ADMIN_PASSWORD", + "SECRET_KEY", + "CREDENTIALS_FILE", + "PLAGIARISM_SHADOW_MODE", + "PLAGIARISM_CACHE_DIR", + "PLAGIARISM_DB_PATH", + ] + print("\nОкружение (.env):") + for key in keys: + val = os.environ.get(key) + if not val: + print(f" {key}= (не задано)") + elif "TOKEN" in key or "PASSWORD" in key or "SECRET" in key: + print(f" {key}=***{val[-4:] if len(val) >= 4 else '****'}") + else: + print(f" {key}={val}") + cred = os.environ.get("CREDENTIALS_FILE", "credentials.json") + print(f" credentials exists: {Path(cred).is_file()}") + jar = ROOT / "tools" / "jplag.jar" + print(f" tools/jplag.jar: {jar.is_file()} ({jar})") + print(f" last course: {load_state().get('course_id')}") + print(f" last group: {load_state().get('group')}") + + +# --------------------------------------------------------------------------- +# Actions +# --------------------------------------------------------------------------- + +def action_grade_student(state: dict) -> None: + print("\n=== Оценка лаб студента (Sheets + плагиат/notes) ===") + course_id = pick_course(state) + if not course_id: + return + group = ask("Группа (лист таблицы)", state.get("group", "4333K")) + state["group"] = group + save_state(state) + github = ask("GitHub логин студента", state.get("github", "")) + state["github"] = github + save_state(state) + + course = load_course_config(course_id) + labs = pick_labs(course) + if labs is None: + return + + shadow = os.environ.get("PLAGIARISM_SHADOW_MODE", "") + print(f"\nPLAGIARISM_SHADOW_MODE={shadow!r}") + if shadow.lower() in ("1", "true", "yes"): + print(" Внимание: shadow — notes в таблицу НЕ пишутся.") + + args = ["--course", course_id, "--group", group, "--github", github, "--labs", *labs] + if not ask_yes_no("Запустить оценку?", True): + return + run_script("grade_student_labs.py", args) + + +def action_batch_plagiarism(state: dict) -> None: + print("\n=== Пакетный плагиат по курсу (без записи в Sheets) ===") + course_id = pick_course(state) + if not course_id: + return + course = load_course_config(course_id) + labs = pick_labs(course) + args = ["--course", course_id] + if labs: + args += ["--labs", *labs] + if ask_yes_no("Пропустить additional (прошлые годы)?", False): + args.append("--skip-additional") + max_repos = ask_optional("Лимит репозиториев на лабу (пусто = все)", "") + if max_repos.isdigit(): + args += ["--max-repos", max_repos] + if not ask_yes_no("Запустить?", True): + return + run_script("plagiarism_batch_course.py", args) + + +def action_show_matches(state: dict) -> None: + print("\n=== Совпадения из SQLite ===") + from grading.plagiarism_store import list_matches + + course_id = pick_course(state) + if not course_id: + return + course = load_course_config(course_id) + labs = pick_labs(course) + if not labs: + return + threshold_raw = ask("Мин. similarity (0..1)", "0.6") + try: + threshold = float(threshold_raw.replace(",", ".")) + except ValueError: + threshold = 0.6 + only_unreviewed = ask_yes_no("Только без review?", False) + + for lab_id in labs: + rows = list_matches( + course_id, + lab_id, + min_similarity=threshold, + include_reviewed=not only_unreviewed, + ) + short = ((course.get("labs") or {}).get(lab_id) or {}).get("short-name") or lab_id + print(f"\n{short} (lab {lab_id}): {len(rows)} пар ≥ {threshold}") + for m in rows[:40]: + rev = "✓" if m.reviewed_by_teacher else " " + print(f" [{rev}] {m.similarity:5.1%} {m.student_a} ↔ {m.student_b}") + if len(rows) > 40: + print(f" … ещё {len(rows) - 40}") + + +def action_compare_engines(state: dict) -> None: + print("\n=== Сравнение compare50 vs JPlag ===") + jar = ROOT / "tools" / "jplag.jar" + if not jar.is_file(): + print(f"Нет {jar}") + print("Скачайте jplag-*-jar-with-dependencies.jar с GitHub releases в tools/jplag.jar") + return + course_id = pick_course(state) + if not course_id: + return + course = load_course_config(course_id) + labs = pick_labs(course) + if not labs: + return + language = ask("Язык JPlag (cpp / python3 / …)", "cpp") + top = ask("Top-N для overlap", "25") + for lab_id in labs: + args = [ + "--course", course_id, + "--lab", lab_id, + "--language", language, + "--top", top, + "--threshold", "0.6", + "--max-matches", "200", + ] + run_script("compare_engines_jplag_compare50.py", args) + + +def action_local_demo(_: dict) -> None: + print("\n=== Локальный demo плагиата (без Sheets) ===") + if ask_yes_no("Синтетический demo (без сети)?", True): + run_script("plagiarism_local_demo.py", []) + else: + print("Для режима repos используйте вручную:") + print(" python scripts/plagiarism_local_demo.py repos --repos ... --files ...") + + +def action_shadow_hint(_: dict) -> None: + print("\n=== Shadow mode ===") + print(f"Сейчас PLAGIARISM_SHADOW_MODE={os.environ.get('PLAGIARISM_SHADOW_MODE')!r}") + print("Меняется в файле .env:") + print(" PLAGIARISM_SHADOW_MODE=true — считать, но не писать notes") + print(" PLAGIARISM_SHADOW_MODE=false — писать notes в таблицу") + print("После правки .env перезапустите меню (load_dotenv при старте).") + + +# --------------------------------------------------------------------------- +# Admin panel: start backend + frontend +# --------------------------------------------------------------------------- + +def _http_ok(url: str, timeout: float = 1.5) -> bool: + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return 200 <= getattr(resp, "status", 200) < 500 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def _detached_popen(cmd: list[str], *, cwd: Path, log_path: Path) -> subprocess.Popen: + """Start a long-running process with stdout/stderr appended to a log file.""" + log_f = open(log_path, "a", encoding="utf-8") + env = os.environ.copy() + # Windows consoles/workers often default to cp1251; emoji in main.py prints crash uvicorn --reload child + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + kwargs: dict = { + "cwd": str(cwd), + "stdout": log_f, + "stderr": subprocess.STDOUT, + "stdin": subprocess.DEVNULL, + "env": env, + } + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + kwargs["start_new_session"] = True + return subprocess.Popen(cmd, **kwargs) + + +def _ensure_frontend_deps() -> bool: + if (FRONTEND_DIR / "node_modules").is_dir(): + return True + print("node_modules нет — ставлю зависимости (npm.cmd install --legacy-peer-deps)…") + npm = "npm.cmd" if sys.platform == "win32" else "npm" + proc = subprocess.run( + [npm, "install", "--legacy-peer-deps"], + cwd=str(FRONTEND_DIR), + ) + if proc.returncode != 0: + print(" Не удалось установить зависимости фронта.") + return False + return True + + +def _find_running_frontend_port() -> int | None: + for port in range(FRONTEND_PORT_START, FRONTEND_PORT_END + 1): + if _http_ok(f"http://127.0.0.1:{port}/"): + return port + return None + + +def _wait_backend(timeout: float = 45.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if _http_ok(f"http://127.0.0.1:{BACKEND_PORT}/docs"): + return True + time.sleep(0.5) + return False + + +def _start_backend(state: dict) -> bool: + if _http_ok(f"http://127.0.0.1:{BACKEND_PORT}/docs"): + print(f" Backend уже работает: http://127.0.0.1:{BACKEND_PORT}") + return True + + LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = LOG_DIR / "menu_backend.log" + log_path.write_text("", encoding="utf-8") + cmd = [ + sys.executable, + "-m", + "uvicorn", + "main:app", + "--reload", + "--host", + "127.0.0.1", + "--port", + str(BACKEND_PORT), + ] + print(" Запуск backend:", " ".join(cmd)) + proc = _detached_popen(cmd, cwd=ROOT, log_path=log_path) + state["backend_pid"] = proc.pid + save_state(state) + print(f" PID={proc.pid}, лог: {log_path}") + print(" Жду готовности…") + if _wait_backend(): + print(f" Backend готов: http://127.0.0.1:{BACKEND_PORT}") + return True + print(" Backend не ответил вовремя. Смотри лог:", log_path) + return False + + +def _start_frontend(state: dict) -> int | None: + existing = _find_running_frontend_port() + if existing is not None: + print(f" Frontend уже работает: http://127.0.0.1:{existing}") + return existing + + if not FRONTEND_DIR.is_dir(): + print(f" Нет каталога {FRONTEND_DIR}") + return None + if not _ensure_frontend_deps(): + return None + + LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = LOG_DIR / "menu_frontend.log" + log_path.write_text("", encoding="utf-8") + + npm = "npm.cmd" if sys.platform == "win32" else "npm" + cmd = [npm, "run", "dev", "--", "--host", "127.0.0.1", "--port", str(FRONTEND_PORT_START)] + print(" Запуск frontend:", " ".join(cmd)) + proc = _detached_popen(cmd, cwd=FRONTEND_DIR, log_path=log_path) + state["frontend_pid"] = proc.pid + save_state(state) + print(f" PID={proc.pid}, лог: {log_path}") + print(" Жду URL Vite…") + + local_re = re.compile(r"Local:\s+(https?://\S+)", re.I) + port_re = re.compile(r":(\d+)/?") + deadline = time.time() + 90.0 + while time.time() < deadline: + try: + text = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + text = "" + m = local_re.search(text) + if m: + url = m.group(1).rstrip("/") + pm = port_re.search(url) + port = int(pm.group(1)) if pm else FRONTEND_PORT_START + if _http_ok(f"http://127.0.0.1:{port}/"): + print(f" Frontend готов: {url}") + return port + found = _find_running_frontend_port() + if found is not None: + print(f" Frontend готов: http://127.0.0.1:{found}") + return found + if proc.poll() is not None: + print(" Frontend процесс завершился. Лог:") + print(text[-2000:] or "(пусто)") + return None + time.sleep(0.5) + + print(" Frontend не поднялся вовремя. Смотри лог:", log_path) + return None + + +def action_start_admin(state: dict) -> None: + print("\n=== Запуск админ-панели (backend + frontend) ===") + print("Нужны: Python (uvicorn), Node.js / npm.cmd") + + ok_back = _start_backend(state) + if not ok_back: + print("Без backend логин в админку не сработает.") + if not ask_yes_no("Всё равно пробовать поднять frontend?", True): + return + + port = _start_frontend(state) + if port is None: + print("Не удалось определить URL фронта.") + return + + admin_url = f"http://127.0.0.1:{port}/admin" + courses_url = f"http://127.0.0.1:{port}/admin/courses" + api_url = f"http://127.0.0.1:{BACKEND_PORT}/docs" + state["admin_url"] = admin_url + state["frontend_port"] = port + save_state(state) + + print() + print("=" * 60) + print(" Админ-панель готова") + print(f" URL: {admin_url}") + print(f" Курсы: {courses_url}") + print(f" API: {api_url}") + print(f" Логин: из .env ADMIN_LOGIN / ADMIN_PASSWORD") + print("=" * 60) + print() + print("Серверы работают в фоне (закрой окна/процессы вручную, когда закончишь).") + + if ask_yes_no("Открыть админку в браузере?", True): + try: + webbrowser.open(admin_url) + print(" Браузер открыт.") + except Exception as exc: + print(f" Не удалось открыть браузер: {exc}") + print(f" Скопируй URL: {admin_url}") + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +MENU = """ +╔══════════════════════════════════════════════════╗ +║ Lab Grader — консольное меню ║ +╚══════════════════════════════════════════════════╝ + 1) Оценить лабы студента (Sheets + плагиат/notes) + 2) Пакетный плагиат по курсу (кэш + SQLite, без Sheets) + 3) Показать совпадения из SQLite + 4) Сравнить compare50 vs JPlag + 5) Локальный demo плагиата + 6) Статус окружения (.env, jplag, …) + 7) Подсказка по SHADOW_MODE + 8) Запустить админ-панель (backend + frontend) → URL + 0) Выход +""" + + +def main() -> int: + os.chdir(ROOT) + state = load_state() + actions = { + "1": action_grade_student, + "2": action_batch_plagiarism, + "3": action_show_matches, + "4": action_compare_engines, + "5": action_local_demo, + "6": lambda s: env_status(), + "7": action_shadow_hint, + "8": action_start_admin, + } + + while True: + shadow = os.environ.get("PLAGIARISM_SHADOW_MODE", "") + admin = state.get("admin_url") or "—" + print(MENU) + print(f" course={state.get('course_id') or '—'} group={state.get('group') or '—'} " + f"SHADOW={shadow or 'off'}") + print(f" admin_url={admin}") + choice = input("\nКоманда: ").strip() + if choice in ("0", "q", "quit", "exit"): + print("Пока.") + return 0 + action = actions.get(choice) + if not action: + print("Неизвестная команда.") + continue + try: + action(state) + except KeyboardInterrupt: + print("\n(прервано)") + except Exception as exc: + print(f"\nОшибка: {exc}") + pause() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plagiarism_batch_course.py b/scripts/plagiarism_batch_course.py new file mode 100644 index 0000000..ce2cd14 --- /dev/null +++ b/scripts/plagiarism_batch_course.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Batch plagiarism run for an entire course org (no grade write, no Sheets). + +Loads course YAML via index id (e.g. os-2025-spring), for each lab with +moss:/plagiarism: + files: downloads matching repos from the course GitHub +org, runs compare50 across the corpus, stores matches in SQLite. + +Does NOT write "v" to Google Sheets — that happens only via POST .../grade. + +Example: + + python scripts/plagiarism_batch_course.py --course os-2025-spring + + # Only labs 2 and 5, skip previous-year additional orgs + python scripts/plagiarism_batch_course.py --course os-2025-spring --labs 2 5 --skip-additional + + # Limit repos per lab (smoke) + python scripts/plagiarism_batch_course.py --course os-2025-spring --max-repos 15 +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +try: + from dotenv import load_dotenv + load_dotenv(ROOT / ".env") +except ImportError: + pass + +from grading.github_client import GitHubClient +from grading.plagiarism import ( + filter_matches_above_threshold, + get_plagiarism_config, + run_compare50, +) +from grading.plagiarism_cache import ( + cache_submission_files, + convert_ipynb_to_py, + get_cache_root, + list_cached_submissions, +) +from grading.plagiarism_store import upsert_matches + + +def load_course_by_id(course_id: str) -> tuple[dict, str]: + """Return (course_config_dict, course_id) from courses/index.yaml.""" + index_path = ROOT / "courses" / "index.yaml" + with open(index_path, encoding="utf-8") as f: + index = yaml.safe_load(f) + + entry = None + for item in index.get("courses", []): + if item.get("id") == course_id: + entry = item + break + if entry is None: + raise SystemExit(f"Course id '{course_id}' not found in courses/index.yaml") + + course_path = ROOT / "courses" / entry["file"] + with open(course_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + if not data or "course" not in data: + raise SystemExit(f"Invalid course file: {course_path}") + return data["course"], course_id + + +def ensure_basefiles( + github: GitHubClient, + course_id: str, + lab_id: str, + basefiles: list | None, + cache_root: Path, +) -> Path | None: + if not basefiles: + return None + base_dir = cache_root / course_id / str(lab_id) / "_basefiles" + base_dir.mkdir(parents=True, exist_ok=True) + any_file = False + for entry in basefiles: + if not isinstance(entry, dict): + continue + repo_full = entry.get("repo", "") + filename = entry.get("filename", "") + if not repo_full or not filename or "/" not in repo_full: + continue + org, repo = repo_full.split("/", 1) + content = github.get_file_content(org, repo, filename) + if content is None: + print(f" WARN basefile missing: {repo_full}/{filename}") + continue + dest = base_dir / org / repo / filename + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(content) + if dest.suffix.lower() == ".ipynb": + convert_ipynb_to_py(dest) + any_file = True + return base_dir if any_file else None + + +def cache_org_lab( + github: GitHubClient, + *, + course_id: str, + lab_id: str, + org: str, + prefix: str, + files: list[str], + local_path: str | None, + cache_root: Path, + max_repos: int | None, +) -> int: + repos = github.list_repos_with_prefix(org, prefix) + if max_repos is not None: + repos = repos[:max_repos] + print(f" {org}: {len(repos)} repos with prefix '{prefix}-'") + cached = 0 + for repo in repos: + result = cache_submission_files( + github, + course_id, + lab_id, + org, + repo, + files, + local_path=local_path, + cache_root=cache_root, + github_prefix=prefix, + ) + if result is not None: + cached += 1 + return cached + + +def run_lab( + github: GitHubClient, + *, + course_id: str, + lab_id: str, + lab_cfg: dict, + org: str, + cache_root: Path, + threshold: float, + skip_additional: bool, + max_repos: int | None, + write_report: bool, +) -> int: + plag = get_plagiarism_config(lab_cfg) + if plag is None or not plag.enabled: + print(f"[lab {lab_id}] skip — no moss:/plagiarism:") + return 0 + + files = lab_cfg.get("files") or [] + prefix = lab_cfg.get("github-prefix") + if not files or not prefix: + print(f"[lab {lab_id}] skip — missing files: or github-prefix") + return 0 + + short = lab_cfg.get("short-name", lab_id) + print(f"\n=== Lab {lab_id} ({short}) prefix={prefix} files={files} ===") + + n = cache_org_lab( + github, + course_id=course_id, + lab_id=lab_id, + org=org, + prefix=prefix, + files=files, + local_path=plag.local_path, + cache_root=cache_root, + max_repos=max_repos, + ) + print(f" cached from course org: {n}") + + if not skip_additional and plag.additional: + for add_org in plag.additional: + try: + n_add = cache_org_lab( + github, + course_id=course_id, + lab_id=lab_id, + org=add_org, + prefix=prefix, + files=files, + local_path=plag.local_path, + cache_root=cache_root, + max_repos=max_repos, + ) + print(f" cached from {add_org}: {n_add}") + except Exception as exc: + print(f" WARN additional org {add_org}: {exc}") + + base_dir = ensure_basefiles( + github, course_id, lab_id, plag.basefiles, cache_root + ) + + dirs = list_cached_submissions(course_id, lab_id, cache_root) + # Exclude _basefiles / _reports style dirs if any slipped in + dirs = [d for d in dirs if not d.name.startswith("_")] + if len(dirs) < 2: + print(f" not enough submissions ({len(dirs)}) — skip compare") + return 0 + + report_dir = None + if write_report: + report_dir = cache_root / course_id / str(lab_id) / "_reports" / "batch_all" + + print(f" comparing {len(dirs)} submissions...") + matches = run_compare50( + dirs, + distro_dirs=[base_dir] if base_dir else [], + max_matches=plag.max_matches, + github_prefix=prefix, + output_dir=report_dir, + ) + thr = plag.threshold if threshold is None else threshold + flagged = filter_matches_above_threshold(matches, thr) + db = cache_root / "plagiarism.db" + upsert_matches(course_id, lab_id, flagged, db_path=db) + + print(f" matches total={len(matches)} flagged(>={thr:.0%})={len(flagged)}") + for m in flagged[:15]: + print(f" {m.student_a} ↔ {m.student_b}: {m.similarity:.0%}") + if len(flagged) > 15: + print(f" ... and {len(flagged) - 15} more") + if report_dir: + print(f" report: {report_dir / 'index.html'}") + return len(flagged) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Batch plagiarism for a course") + parser.add_argument("--course", required=True, help="Course id from index.yaml") + parser.add_argument("--labs", nargs="*", help="Optional lab ids to include (default: all)") + parser.add_argument("--skip-additional", action="store_true", + help="Do not download moss.additional orgs") + parser.add_argument("--max-repos", type=int, default=None, + help="Cap repos per org (smoke tests)") + parser.add_argument("--threshold", type=float, default=None, + help="Override lab threshold") + parser.add_argument("--no-report", action="store_true", help="Skip HTML reports") + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN not set (put it in .env)") + return 1 + + course, course_id = load_course_by_id(args.course) + org = (course.get("github") or {}).get("organization") + if not org: + print("Course has no github.organization") + return 1 + + labs = course.get("labs") or {} + lab_ids = args.labs if args.labs else list(labs.keys()) + + cache_root = get_cache_root() + cache_root.mkdir(parents=True, exist_ok=True) + github = GitHubClient(token) + + print(f"Course {course_id} org={org}") + print(f"Cache: {cache_root.resolve()}") + print(f"Labs: {lab_ids}") + + total_flagged = 0 + for lab_id in lab_ids: + lab_cfg = labs.get(str(lab_id)) or labs.get(lab_id) + if not isinstance(lab_cfg, dict): + print(f"[lab {lab_id}] not found in YAML") + continue + total_flagged += run_lab( + github, + course_id=course_id, + lab_id=str(lab_id), + lab_cfg=lab_cfg, + org=org, + cache_root=cache_root, + threshold=args.threshold, + skip_additional=args.skip_additional, + max_repos=args.max_repos, + write_report=not args.no_report, + ) + + print(f"\nDone. Total flagged pairs: {total_flagged}") + print(f"SQLite: {cache_root / 'plagiarism.db'}") + print(f"Admin: /admin/courses/{course_id}/labs → plagiarism") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plagiarism_local_demo.py b/scripts/plagiarism_local_demo.py new file mode 100644 index 0000000..83f483f --- /dev/null +++ b/scripts/plagiarism_local_demo.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Local plagiarism smoke-test — no Google, org token optional. + +Modes: + 1) demo (default) — synthetic local files, zero network + 2) repos — download files from GitHub repos YOU can read + (personal token / public repos is enough) + +Examples (from repo root): + + # Pure local demo + python scripts/plagiarism_local_demo.py + + # Your own / public repos (needs GITHUB_TOKEN with read access) + python scripts/plagiarism_local_demo.py repos ^ + --repos markpolyak/some-lab-alice markpolyak/some-lab-bob ^ + --files main.py ^ + --prefix some-lab + +Results: + plagiarism_cache/local-demo/... + plagiarism_cache/plagiarism.db + Optional HTML report under _reports/ +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +try: + from dotenv import load_dotenv + load_dotenv(ROOT / ".env") +except ImportError: + pass + +from grading.github_client import GitHubClient +from grading.plagiarism import ( + compare_submission_against_cache, + filter_matches_above_threshold, + run_compare50, +) +from grading.plagiarism_cache import cache_submission_files, get_cache_root +from grading.plagiarism_store import list_matches, upsert_matches + +IDENTICAL = "\n".join( + [ + "def foo(x):", + " total = 0", + " for i in range(x):", + " total += i * 2", + " if i % 3 == 0:", + " total -= 1", + " return total", + "", + "def helper(values):", + " result = []", + " for v in values:", + " result.append(foo(v))", + " return result", + "", + "print(helper([1, 2, 3, 4, 5]))", + ] +) + +DIFFERENT = "\n".join( + [ + "class Calculator:", + " def __init__(self, name):", + " self.name = name", + " self.history = []", + "", + " def compute(self, items):", + " acc = 1", + " for item in items:", + " acc *= max(item, 1)", + " self.history.append(acc)", + " return acc", + "", + "calc = Calculator('demo')", + "print(calc.compute([2, 3, 4]))", + ] +) + + +def _print_matches(matches) -> None: + if not matches: + print("No matches above threshold.") + return + print(f"{'student_a':<20} {'student_b':<20} similarity") + print("-" * 55) + for m in matches: + sim = getattr(m, "similarity", m.get("similarity") if isinstance(m, dict) else 0) + a = getattr(m, "student_a", m.get("student_a") if isinstance(m, dict) else "?") + b = getattr(m, "student_b", m.get("student_b") if isinstance(m, dict) else "?") + print(f"{a:<20} {b:<20} {sim:.0%}") + + +def run_demo(cache_root: Path, threshold: float) -> int: + course_id, lab_id, org, prefix = "local-demo", "1", "demo-org", "lab" + base = cache_root / course_id / lab_id / org + + alice = base / f"{prefix}-alice" + bob = base / f"{prefix}-bob" + carol = base / f"{prefix}-carol" + for d in (alice, bob, carol): + d.mkdir(parents=True, exist_ok=True) + + (alice / "main.py").write_text(IDENTICAL, encoding="utf-8") + (bob / "main.py").write_text(IDENTICAL, encoding="utf-8") + (carol / "main.py").write_text(DIFFERENT, encoding="utf-8") + + print(f"Wrote fixtures under {base}") + matches = compare_submission_against_cache( + course_id, + lab_id, + org, + f"{prefix}-alice", + cache_root=cache_root, + github_prefix=prefix, + write_report=True, + ) + flagged = filter_matches_above_threshold(matches, threshold) + db = cache_root / "plagiarism.db" + upsert_matches(course_id, lab_id, flagged, db_path=db) + + print(f"\nThreshold: {threshold:.0%}") + _print_matches(flagged) + stored = list_matches(course_id, lab_id, db_path=db) + print(f"\nSQLite rows in {db}: {len(stored)}") + print("Admin UI would show these via GET /courses/local-demo/labs/1/plagiarism") + return 0 if flagged else 1 + + +def run_repos( + cache_root: Path, + *, + repos: list[str], + files: list[str], + prefix: str, + threshold: float, + token: str, +) -> int: + github = GitHubClient(token) + course_id, lab_id = "local-demo", "repos" + + cached_dirs = [] + for full in repos: + if "/" not in full: + print(f"Skip invalid repo (need org/name): {full}") + continue + org, repo = full.split("/", 1) + result = cache_submission_files( + github, + course_id, + lab_id, + org, + repo, + files, + cache_root=cache_root, + github_prefix=prefix, + ) + if result is None: + print(f"WARN: nothing cached for {full}") + else: + print(f"Cached {full} -> {result.directory}") + cached_dirs.append(result.directory) + + if len(cached_dirs) < 2: + print("Need at least 2 successfully cached repos to compare.") + return 1 + + # Compare first as "new", rest as archive (incremental style) + new_dir = cached_dirs[0] + org = new_dir.parent.name + repo = new_dir.name + + matches = compare_submission_against_cache( + course_id, + lab_id, + org, + repo, + cache_root=cache_root, + github_prefix=prefix, + write_report=True, + ) + flagged = filter_matches_above_threshold(matches, threshold) + db = cache_root / "plagiarism.db" + upsert_matches(course_id, lab_id, flagged, db_path=db) + print(f"\nThreshold: {threshold:.0%}") + _print_matches(flagged) + print(f"SQLite: {db}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Local plagiarism smoke-test") + parser.add_argument( + "mode", + nargs="?", + default="demo", + choices=["demo", "repos"], + help="demo = local fixtures; repos = download from GitHub", + ) + parser.add_argument( + "--cache-dir", + default=None, + help="Cache root (default: ./plagiarism_cache or PLAGIARISM_CACHE_DIR)", + ) + parser.add_argument("--threshold", type=float, default=0.6) + parser.add_argument( + "--repos", + nargs="+", + default=[], + help="org/repo list (repos mode)", + ) + parser.add_argument( + "--files", + nargs="+", + default=["main.py"], + help="File paths inside each repo (repos mode)", + ) + parser.add_argument( + "--prefix", + default="lab", + help="Repo name prefix before student login, e.g. os-task1", + ) + args = parser.parse_args() + + if args.cache_dir: + os.environ["PLAGIARISM_CACHE_DIR"] = args.cache_dir + cache_root = get_cache_root() + cache_root.mkdir(parents=True, exist_ok=True) + print(f"Cache root: {cache_root.resolve()}") + + if args.mode == "demo": + return run_demo(cache_root, args.threshold) + + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + print("Set GITHUB_TOKEN (personal token with read access is enough).") + return 1 + if len(args.repos) < 2: + print("Provide at least two --repos org/name ...") + return 1 + return run_repos( + cache_root, + repos=args.repos, + files=args.files, + prefix=args.prefix, + threshold=args.threshold, + token=token, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_grade_lab_characterization.py b/tests/test_grade_lab_characterization.py index 91efe38..2acf5d1 100644 --- a/tests/test_grade_lab_characterization.py +++ b/tests/test_grade_lab_characterization.py @@ -13,6 +13,8 @@ import sys import os +from fastapi import BackgroundTasks + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -87,7 +89,7 @@ def test_success_all_checks_pass( # Import and call from main import grade_lab, GradeRequest grade_request = GradeRequest(github="testuser") - result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert result["status"] == "updated" assert result["result"] == "v" @@ -150,7 +152,7 @@ def test_failure_some_checks_fail( from main import grade_lab, GradeRequest grade_request = GradeRequest(github="testuser") - result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert result["status"] == "updated" assert result["result"] == "x" @@ -179,7 +181,7 @@ def test_missing_test_main_py( grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 400 assert "test_main.py" in exc_info.value.detail @@ -213,7 +215,7 @@ def test_missing_workflows( grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 400 assert "workflows" in exc_info.value.detail.lower() @@ -253,7 +255,7 @@ def test_no_commits( grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 404 assert "коммит" in exc_info.value.detail.lower() @@ -302,7 +304,7 @@ def test_forbidden_test_file_modification( grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 403 assert "test_main.py" in exc_info.value.detail @@ -351,7 +353,7 @@ def test_forbidden_tests_folder_modification( grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 403 assert "tests" in exc_info.value.detail.lower() @@ -400,7 +402,7 @@ def test_no_ci_checks_pending( from main import grade_lab, GradeRequest grade_request = GradeRequest(github="testuser") - result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert result["status"] == "pending" assert "⏳" in result["message"] @@ -459,7 +461,7 @@ def test_github_user_not_in_spreadsheet( grade_request = GradeRequest(github="unknownuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 404 assert "не найден" in exc_info.value.detail.lower() @@ -479,7 +481,7 @@ def test_missing_course_configuration(self, mock_request): grade_request = GradeRequest(github="testuser") with pytest.raises(HTTPException) as exc_info: - grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) assert exc_info.value.status_code == 400 @@ -517,7 +519,7 @@ def test_success_response_format( from main import grade_lab, GradeRequest grade_request = GradeRequest(github="testuser") - result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request) + result = grade_lab(mock_request, "test-course", "group1", "ЛР1", grade_request, BackgroundTasks()) # Verify response structure assert "status" in result diff --git a/tests/test_plagiarism.py b/tests/test_plagiarism.py new file mode 100644 index 0000000..c697649 --- /dev/null +++ b/tests/test_plagiarism.py @@ -0,0 +1,194 @@ +""" +Unit tests for grading/plagiarism.py + +Uses real compare50 API on local fixtures (identical / different / shared template). +""" +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from grading.plagiarism import ( + DEFAULT_PLAGIARISM_THRESHOLD, + PlagiarismConfig, + compare_submission_against_cache, + filter_matches_above_threshold, + get_plagiarism_config, + run_compare50, +) + + +IDENTICAL_CODE = "\n".join( + [ + "def foo(x):", + " total = 0", + " for i in range(x):", + " total += i * 2", + " if i % 3 == 0:", + " total -= 1", + " return total", + "", + "def helper(values):", + " result = []", + " for v in values:", + " result.append(foo(v))", + " return result", + "", + "print(helper([1, 2, 3, 4, 5]))", + ] +) + +DIFFERENT_CODE = "\n".join( + [ + "class Calculator:", + " def __init__(self, name):", + " self.name = name", + " self.history = []", + "", + " def compute(self, items):", + " acc = 1", + " for item in items:", + " acc *= max(item, 1)", + " self.history.append(acc)", + " return acc", + "", + "calc = Calculator('demo')", + "print(calc.compute([2, 3, 4]))", + ] +) + +TEMPLATE_CODE = "\n".join( + [ + "# Lab template — do not copy solutions", + "def main():", + " print('TODO: implement')", + "", + "if __name__ == '__main__':", + " main()", + ] +) + + +def _write_submission(root: Path, org: str, repo: str, code: str, filename: str = "main.py") -> Path: + dest = root / org / repo + dest.mkdir(parents=True, exist_ok=True) + (dest / filename).write_text(code, encoding="utf-8") + return dest + + +class TestGetPlagiarismConfig: + def test_missing_returns_none(self): + assert get_plagiarism_config({}) is None + assert get_plagiarism_config({"files": ["a.py"]}) is None + + def test_moss_alias(self): + cfg = get_plagiarism_config({"moss": {"language": "python", "threshold": 0.7}}) + assert isinstance(cfg, PlagiarismConfig) + assert cfg.language == "python" + assert cfg.threshold == 0.7 + assert cfg.engine == "compare50" + + def test_plagiarism_preferred_over_moss(self): + cfg = get_plagiarism_config({ + "moss": {"language": "c"}, + "plagiarism": {"language": "python", "engine": "compare50"}, + }) + assert cfg.language == "python" + + def test_default_threshold(self): + cfg = get_plagiarism_config({"plagiarism": {"language": "cc"}}) + assert cfg.threshold == DEFAULT_PLAGIARISM_THRESHOLD + + def test_disabled(self): + cfg = get_plagiarism_config({"plagiarism": False}) + assert cfg is not None + assert cfg.enabled is False + + +class TestRunCompare50: + def test_identical_code_high_similarity(self, tmp_path): + a = _write_submission(tmp_path, "org", "lab-alice", IDENTICAL_CODE) + b = _write_submission(tmp_path, "org", "lab-bob", IDENTICAL_CODE) + + matches = run_compare50([a, b], github_prefix="lab") + + assert matches, "identical submissions should produce a match" + top = matches[0] + assert {top.student_a, top.student_b} == {"alice", "bob"} + assert top.similarity == pytest.approx(1.0) + + def test_different_code_no_or_low_match(self, tmp_path): + a = _write_submission(tmp_path, "org", "lab-alice", IDENTICAL_CODE) + b = _write_submission(tmp_path, "org", "lab-bob", DIFFERENT_CODE) + + matches = run_compare50([a, b], github_prefix="lab") + # Completely different structure typically yields no scores + assert all(m.similarity < 0.5 for m in matches) + + def test_shared_template_excluded(self, tmp_path): + # Both students start from template + identical solution body + solution = IDENTICAL_CODE + a = _write_submission(tmp_path / "subs", "org", "lab-alice", TEMPLATE_CODE + "\n" + solution) + b = _write_submission(tmp_path / "subs", "org", "lab-bob", TEMPLATE_CODE + "\n" + solution) + distro = tmp_path / "distro" + distro.mkdir() + (distro / "template.py").write_text(TEMPLATE_CODE, encoding="utf-8") + + matches = run_compare50([a, b], distro_dirs=[distro], github_prefix="lab") + # Still similar due to shared solution, but should not crash; template stripped + assert isinstance(matches, list) + + def test_incremental_new_vs_archives(self, tmp_path): + new = _write_submission(tmp_path, "org", "lab-alice", IDENTICAL_CODE) + old1 = _write_submission(tmp_path, "org", "lab-bob", IDENTICAL_CODE) + old2 = _write_submission(tmp_path, "org", "lab-carol", DIFFERENT_CODE) + + matches = run_compare50( + [new], + archive_dirs=[old1, old2], + github_prefix="lab", + ) + assert matches + assert matches[0].similarity == pytest.approx(1.0) + students = {matches[0].student_a, matches[0].student_b} + assert "alice" in students + assert "bob" in students + + def test_not_enough_submissions(self, tmp_path): + only = _write_submission(tmp_path, "org", "lab-alice", IDENTICAL_CODE) + assert run_compare50([only], github_prefix="lab") == [] + + +class TestCompareAgainstCache: + def test_compares_new_against_existing(self, tmp_path): + lab_root = tmp_path / "course" / "1" + _write_submission(lab_root, "org", "lab-bob", IDENTICAL_CODE) + _write_submission(lab_root, "org", "lab-alice", IDENTICAL_CODE) + + matches = compare_submission_against_cache( + "course", + "1", + "org", + "lab-alice", + cache_root=tmp_path, + github_prefix="lab", + write_report=False, + ) + assert matches + assert matches[0].similarity == pytest.approx(1.0) + + +class TestFilterMatches: + def test_filters_by_threshold(self): + from grading.plagiarism import PlagiarismMatch + + matches = [ + PlagiarismMatch("a", "b", 0.9), + PlagiarismMatch("a", "c", 0.4), + PlagiarismMatch("b", "c", 0.6), + ] + filtered = filter_matches_above_threshold(matches, 0.6) + assert [m.similarity for m in filtered] == [0.9, 0.6] diff --git a/tests/test_plagiarism_cache.py b/tests/test_plagiarism_cache.py new file mode 100644 index 0000000..239dac5 --- /dev/null +++ b/tests/test_plagiarism_cache.py @@ -0,0 +1,246 @@ +""" +Unit tests for grading/plagiarism_cache.py +""" +import base64 +import json +import os +import sys +from pathlib import Path + +import pytest +import responses + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from grading.github_client import GitHubClient +from grading.plagiarism_cache import ( + CachedSubmission, + cache_submission_files, + convert_ipynb_to_py, + extract_student_from_repo, + get_cache_root, + list_cached_submissions, + submission_cache_dir, +) + + +class TestExtractStudentFromRepo: + def test_with_prefix(self): + assert extract_student_from_repo("os-task1-alice", "os-task1") == "alice" + + def test_without_matching_prefix(self): + assert extract_student_from_repo("other-repo", "os-task1") == "other-repo" + + +class TestCachePaths: + def test_submission_cache_dir(self, tmp_path): + path = submission_cache_dir("os-2026", "2", "suai-os", "os-task2-bob", tmp_path) + assert path == tmp_path / "os-2026" / "2" / "suai-os" / "os-task2-bob" + + def test_get_cache_root_env(self, monkeypatch, tmp_path): + monkeypatch.setenv("PLAGIARISM_CACHE_DIR", str(tmp_path / "custom")) + assert get_cache_root() == tmp_path / "custom" + + def test_get_cache_root_explicit(self, tmp_path): + assert get_cache_root(tmp_path) == tmp_path + + +class TestConvertIpynbToPy: + def test_converts_code_cells(self, tmp_path): + nb = { + "cells": [ + {"cell_type": "markdown", "source": ["# Title"]}, + {"cell_type": "code", "source": ["x = 1\n", "print(x)\n"]}, + {"cell_type": "code", "source": "y = 2\n"}, + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + ipynb = tmp_path / "exercises.ipynb" + ipynb.write_text(json.dumps(nb), encoding="utf-8") + + py_path = convert_ipynb_to_py(ipynb) + assert py_path == tmp_path / "exercises.py" + content = py_path.read_text(encoding="utf-8") + assert "x = 1" in content + assert "y = 2" in content + assert "# Title" not in content + + def test_check_existing_reuses_file(self, tmp_path): + ipynb = tmp_path / "nb.ipynb" + ipynb.write_text(json.dumps({"cells": []}), encoding="utf-8") + existing = tmp_path / "nb.py" + existing.write_text("already here", encoding="utf-8") + + result = convert_ipynb_to_py(ipynb, check_existing=True) + assert result.read_text(encoding="utf-8") == "already here" + + def test_rejects_non_ipynb(self, tmp_path): + py = tmp_path / "a.py" + py.write_text("x = 1", encoding="utf-8") + with pytest.raises(ValueError): + convert_ipynb_to_py(py) + + +def _mock_file_content(org: str, repo: str, path: str, content: bytes, status: int = 200): + """Register a GitHub contents API mock for a file.""" + url = f"https://api.github.com/repos/{org}/{repo}/contents/{path}" + if status != 200: + responses.add(responses.GET, url, json={"message": "Not Found"}, status=status) + return + responses.add( + responses.GET, + url, + json={ + "name": path.split("/")[-1], + "path": path, + "encoding": "base64", + "content": base64.b64encode(content).decode("ascii"), + "type": "file", + }, + status=200, + ) + + +class TestCacheSubmissionFiles: + @responses.activate + def test_downloads_and_caches_files(self, tmp_path): + _mock_file_content("org", "lab-alice", "main.cpp", b"int main() {}") + client = GitHubClient("token") + + result = cache_submission_files( + client, + course_id="os-2026", + lab_id="1", + org="org", + repo="lab-alice", + files=["main.cpp"], + cache_root=tmp_path, + github_prefix="lab", + ) + + assert isinstance(result, CachedSubmission) + assert result.student == "alice" + assert result.files[0].exists() + assert result.files[0].read_bytes() == b"int main() {}" + expected = tmp_path / "os-2026" / "1" / "org" / "lab-alice" / "main.cpp" + assert expected.exists() + + @responses.activate + def test_converts_ipynb_on_cache(self, tmp_path): + nb = { + "cells": [{"cell_type": "code", "source": ["a = 1\n"]}], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + _mock_file_content( + "org", "1-bob", "exercises.ipynb", + json.dumps(nb).encode("utf-8"), + ) + client = GitHubClient("token") + + result = cache_submission_files( + client, + course_id="ml-2026", + lab_id="1", + org="org", + repo="1-bob", + files=["exercises.ipynb"], + cache_root=tmp_path, + github_prefix="1", + ) + + assert result is not None + assert result.files[0].suffix == ".py" + assert "a = 1" in result.files[0].read_text(encoding="utf-8") + assert (result.directory / "exercises.ipynb").exists() + + @responses.activate + def test_local_path_prefix(self, tmp_path): + _mock_file_content("org", "lab-carol", "lab3/solution.py", b"print(1)") + client = GitHubClient("token") + + result = cache_submission_files( + client, + course_id="c1", + lab_id="3", + org="org", + repo="lab-carol", + files=["solution.py"], + local_path="lab3", + cache_root=tmp_path, + github_prefix="lab", + ) + + assert result is not None + cached = tmp_path / "c1" / "3" / "org" / "lab-carol" / "solution.py" + assert cached.read_bytes() == b"print(1)" + + @responses.activate + def test_fallback_when_local_path_misses(self, tmp_path): + # Prefixed path 404, root path succeeds (matches grading file checks) + _mock_file_content("org", "lab-dave", "lab1/main.py", b"", status=404) + _mock_file_content("org", "lab-dave", "main.py", b"ok") + client = GitHubClient("token") + + result = cache_submission_files( + client, + course_id="c1", + lab_id="1", + org="org", + repo="lab-dave", + files=["main.py"], + local_path="lab1", + cache_root=tmp_path, + github_prefix="lab", + ) + + assert result is not None + assert result.files[0].read_bytes() == b"ok" + + @responses.activate + def test_returns_none_when_all_missing(self, tmp_path): + _mock_file_content("org", "lab-eve", "gone.py", b"", status=404) + client = GitHubClient("token") + + result = cache_submission_files( + client, + course_id="c1", + lab_id="1", + org="org", + repo="lab-eve", + files=["gone.py"], + cache_root=tmp_path, + ) + assert result is None + + def test_returns_none_when_no_files_configured(self, tmp_path): + client = GitHubClient("token") + result = cache_submission_files( + client, + course_id="c1", + lab_id="1", + org="org", + repo="lab-frank", + files=[], + cache_root=tmp_path, + ) + assert result is None + + +class TestListCachedSubmissions: + def test_lists_org_repo_dirs(self, tmp_path): + (tmp_path / "c1" / "2" / "org-a" / "repo-1").mkdir(parents=True) + (tmp_path / "c1" / "2" / "org-a" / "repo-2").mkdir(parents=True) + (tmp_path / "c1" / "2" / "org-b" / "repo-3").mkdir(parents=True) + # File under lab root should be ignored + (tmp_path / "c1" / "2" / "readme.txt").write_text("x") + + listed = list_cached_submissions("c1", "2", tmp_path) + names = {p.name for p in listed} + assert names == {"repo-1", "repo-2", "repo-3"} + + def test_empty_when_missing(self, tmp_path): + assert list_cached_submissions("missing", "1", tmp_path) == [] diff --git a/tests/test_plagiarism_store.py b/tests/test_plagiarism_store.py new file mode 100644 index 0000000..dd87025 --- /dev/null +++ b/tests/test_plagiarism_store.py @@ -0,0 +1,64 @@ +""" +Unit tests for grading/plagiarism_store.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from grading.plagiarism import PlagiarismMatch +from grading.plagiarism_store import list_matches, mark_reviewed, upsert_matches + + +class TestPlagiarismStore: + def test_upsert_and_list(self, tmp_path): + db = tmp_path / "plagiarism.db" + matches = [ + PlagiarismMatch("bob", "alice", 0.9, details="/report"), + PlagiarismMatch("carol", "alice", 0.5), + ] + assert upsert_matches("c1", "2", matches, db_path=db) == 2 + + rows = list_matches("c1", "2", db_path=db) + assert len(rows) == 2 + assert rows[0].student_a == "alice" # ordered pair + assert rows[0].student_b == "bob" + assert rows[0].similarity == 0.9 + + high = list_matches("c1", "2", min_similarity=0.8, db_path=db) + assert len(high) == 1 + + def test_preserves_reviewed_on_upsert(self, tmp_path): + db = tmp_path / "plagiarism.db" + upsert_matches( + "c1", "1", + [PlagiarismMatch("a", "b", 0.8)], + db_path=db, + ) + assert mark_reviewed("c1", "1", "b", "a", db_path=db) is True + + # Re-upsert should not clear reviewed flag / should skip update + upsert_matches( + "c1", "1", + [PlagiarismMatch("a", "b", 0.95, details="new")], + db_path=db, + ) + rows = list_matches("c1", "1", db_path=db) + assert len(rows) == 1 + assert rows[0].reviewed_by_teacher is True + assert rows[0].similarity == 0.8 # unchanged + + def test_filter_unreviewed(self, tmp_path): + db = tmp_path / "plagiarism.db" + upsert_matches( + "c1", "1", + [ + PlagiarismMatch("a", "b", 0.9), + PlagiarismMatch("a", "c", 0.7), + ], + db_path=db, + ) + mark_reviewed("c1", "1", "a", "b", db_path=db) + unreviewed = list_matches("c1", "1", include_reviewed=False, db_path=db) + assert len(unreviewed) == 1 + assert unreviewed[0].student_b == "c" diff --git a/tests/test_sheets_comments.py b/tests/test_sheets_comments.py new file mode 100644 index 0000000..7c1b500 --- /dev/null +++ b/tests/test_sheets_comments.py @@ -0,0 +1,37 @@ +""" +Unit tests for grading/sheets_comments.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from grading.sheets_comments import a1_notation, format_plagiarism_comment + + +class TestA1Notation: + def test_basic(self): + assert a1_notation(1, 1) == "A1" + assert a1_notation(3, 2) == "B3" + assert a1_notation(10, 26) == "Z10" + assert a1_notation(5, 27) == "AA5" + + +class TestFormatComment: + def test_includes_peers_and_percent(self): + text = format_plagiarism_comment( + lab_short_name="ЛР2", + student="alice", + matches=[ + {"student_a": "alice", "student_b": "bob", "similarity": 0.85}, + {"student_a": "carol", "student_b": "alice", "similarity": 0.7}, + ], + cell_a1="D5", + ) + assert "ЛР2" in text + assert "alice" in text + assert "bob" in text + assert "carol" in text + assert "85%" in text + assert "D5" in text + assert "админ-панели" in text