feat: automatic plagiarism detection (Issue #45) - #47
Conversation
Implement local compare50 checks after writing v to Sheets, with source cache, SQLite matches, cell notes, and admin UI per docs/PLAGIARISM_DETECTION_PLAN.md (closes issue markpolyak#45 phases 0-6). Co-authored-by: Cursor <cursoragent@cursor.com>
grade_lab now schedules plagiarism checks via BackgroundTasks; update calls so CI characterization tests match the new signature. Co-authored-by: Cursor <cursoragent@cursor.com>
markpolyak
left a comment
There was a problem hiding this comment.
Ревью (multi-angle, high effort)
PR большой (5206 добавленных строк, 37 файлов), реализует локальную проверку на плагиат через compare50 после успешной оценки. Ниже — находки, отсортированные по серьёзности, прошедшие независимую верификацию.
Баги (влияют на корректность/данные)
-
Файлы-шаблоны (
basefiles) попадают в сравнение как «поддельная» студенческая работа —grading/plagiarism_cache.py(list_cached_submissions, ~L224). Функция обходит все поддиректорииlab_rootкак «организации», не исключая служебную папку_basefiles, куда_ensure_basefilesкладёт код шаблона. В результате шаблонный код сравнивается через compare50 наравне со студентами → ложные обвинения в плагиате для тех, кто честно использовал шаблон как есть. -
Заметка о плагиате может уйти не в ту ячейку —
main.py(~L936) передаётcell_row/cell_colпо значению вBackgroundTasksпри постановке задачи, а сама проверка (скачивание репозиториев + compare50) может выполняться секунды-минуты. Если за это время студент пересдаёт работу и позиция в таблице меняется, заметка о плагиате уйдёт в устаревшую/чужую ячейку — нет повторной проверки актуальности перед записью. -
Блокировки SQLite тихо проглатываются —
grading/plagiarism_store.py(~L30):sqlite3.connect()безtimeout=/WAL, аrun_plagiarism_checkоборачивает всё в широкийexcept Exception: logger.exception(...). При параллельных фоновых проверках возможна ошибкаdatabase is locked, которая молча проглатывается — совпадения для студента просто не сохраняются, без какого-либо сигнала. -
URL к GitHub Contents API не экранируется —
grading/github_client.py(get_file_content, ~L96). Путь к файлу подставляется в f-string без URL-кодирования; имя файла с пробелом/спецсимволом ломает запрос, GitHub отвечает ошибкой, и файл молча пропускается из кэша (толькоdebug-лог) — часть кода студента вообще не участвует в проверке. -
Конфиг
plagiarism.language— нерабочая опция — полеPlagiarismConfig.languageпарсится из YAML, но нигде не используется в реальном пайплайне (run_plagiarism_check/run_compare50его не читают). Преподаватель может выставитьlanguage: cppи не получить ни ошибки, ни эффекта — тихий no-op. -
Заметка на ячейке перезаписывается, а не дополняется —
grading/sheets_comments.py(set_cell_note, ~L84). Каждый новый прогон полностью заменяет содержимое заметки текущими совпадениями, теряя информацию о более ранних находках. -
Потенциальный path traversal через конфиг
files:(правдоподобно, не 100% доказано) —grading/plagiarism_cache.py(~L178): имя файла берётся напрямую из YAML-конфига лабы и join'ится в путь кэша без санитизации (../абсолютные пути могут вывести запись за пределыplagiarism_cache/...). -
Monkey-patch глобального состояния compare50 без блокировки —
grading/plagiarism.py(run_compare50, ~L178) патчитcompare50._data.File.read/compare50._api.Executorна уровне модуля.BackgroundTasksв Starlette реально исполняются в threadpool, так что при двух параллельных фоновых проверках это гонка (сейчас безобидная, т.к. оба потока пишут одинаковые значения, но заложена бомба замедленного действия).
Дублирование кода / архитектура
-
_ensure_basefilesпродублирован и уже разошёлся междуgrading/plagiarism_check.py(L46-85) иscripts/plagiarism_batch_course.py(L76-106) — разная защита от некорректногоrepo_full, разное логирование. Стоит вынести в общий helper. -
Аутентификация Google Sheets задублирована —
grading/sheets_comments.pyреализует собственныйServiceAccountCredentials/gspread.authorizeнезависимо от уже открытой вmain.pyсессии. Каждая проверка на плагиат — лишний полный auth-round-trip к Sheets API вместо переиспользования уже открытого листа.
Проверено и опровергнуто
- Дефолт
max-matches250→50 — не регрессия, фича полностью новая, старого дефолта не существовало. - Смена дефолта
CREDENTIALS_FILEв.env.example— не проблема,docker-compose.example.yamlявно переопределяет путь для Docker. - Версионная ветка
hasattr(sheet, 'update_note')— на gspread 6.2.1 оба метода эквивалентны, первая заметка не ломается. - Проверка
cell_row and cell_colна truthy — 0 недостижим на практике (индексы всегда ≥3/≥1).
Рекомендация
Самое важное до мержа — пункты 1 и 2 (ложные совпадения с шаблоном и запись заметки не в ту ячейку): оба напрямую бьют по доверию к системе. Остальное можно чинить отдельными PR после мержа.
🤖 Автоматическое multi-angle ревью (8 угла поиска + верификация), выполненное Claude Code.
Summary
vgrade (FastAPIBackgroundTasks)plagiarism:with deprecatedmoss:alias; docs, tests, CLIscripts/menu.pyCloses #45
Plan phases (docs/PLAGIARISM_DETECTION_PLAN.md)
PLAGIARISM_SHADOW_MODE)Test plan
pytest tests/test_plagiarism*.py tests/test_sheets_comments.py -vpython scripts/menu.py→ 8 → admin login → course → lab → plagiarism list → Mark reviewedPLAGIARISM_SHADOW_MODE=false→ note appears on Sheets cellMade with Cursor