From b471ec767a5398c518035d352a572ab837736873 Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 24 Aug 2026 15:09:43 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=B2=D0=B5=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=B2=20=D0=B4=D1=83?= =?UTF-8?q?=D1=85=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D1=80=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D1=85=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=20=E2=80=94=20=D1=86=D0=B8=D1=82=D0=B0=D1=82=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE-=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ответ на "неужели больше нет" / "мы ведь можем это исправить": закрывает две дыры относительно Антиплагиат.ру/Turnitin, о которых договорились. 1. Различение цитаты и голого плагиата (app.scoring.is_cited, worker-gpu): эвристика — фрагмент считается процитированным, если обрамлён кавычками («…», "…") либо сразу за ним (в пределах ~150 симв.) идёт скобочная ссылка с годом: [Иванов, 2023], (Smith, 2020) — совпадает и с нашим же форматом ГОСТ 7.0.5. aggregate_results теперь принимает full_text, размечает match["cited"] и считает uncited_similarity (доля БЕЗ похожих на цитаты — ближе к тому, что коммерческие системы называют "% некорректных заимствований") отдельно от overall_similarity (как было, для совместимости). Фронтенд: бейдж "Цитата" на совпадении + строка с разбивкой в отчёте (аддитивные опциональные поля в типах — старые задачи не ломаются). 2. Автопополнение корпуса проверенными работами (как у коммерческих систем — так ловится списывание у предыдущих потоков). Раньше загруженная на проверку работа складывалась в StagedWork и ждала РУЧНОГО одобрения админом — де-факто не пополняла базу для сравнения. Теперь index.auto_approve_submission (диспатчится из gpu.check_plagiarism ПОСЛЕ сохранения результата — чтобы работа не сматчилась сама с собой) добавляет её в documents автоматически, под настройкой AUTO_APPROVE_SUBMISSIONS (default True). Ручное approve/reject в админке остаётся рабочим (идемпотентно — auto-approve пропускает уже не-pending записи), пригодится при AUTO_APPROVE=False. Конвертация StagedWork→doc_data вынесена в чистый app/staging.py (без Celery/SQLAlchemy/MinIO) — тестируется изолированно, идентична ручному пути в admin.py (POST /admin/staging/{id}/approve). Тестов добавлено 16 (scoring 8→18, новый staging.py — 6). Оба mypy-гейта расширены (scoring.py, staging.py). Тестов всего: 112 (было 82 в последнем подсчёте README — таблица давно отставала, заодно поправил на актуальные цифры по всем сервисам, включая забытый в прошлый раз Qdrant). Co-Authored-By: Claude Opus 4.8 --- README.md | 12 ++- scripts/run_lint.sh | 2 +- .../src/components/PlagiarismReport.tsx | 14 ++++ services/frontend/src/types/index.ts | 9 +++ services/worker-gpu/app/scoring.py | 80 ++++++++++++++++--- services/worker-gpu/app/tasks/plagiarism.py | 12 ++- services/worker-gpu/tests/test_scoring.py | 74 ++++++++++++++++- services/worker-indexer/app/config.py | 7 ++ services/worker-indexer/app/staging.py | 40 ++++++++++ services/worker-indexer/app/tasks/index.py | 53 +++++++++++- services/worker-indexer/tests/test_staging.py | 51 ++++++++++++ 11 files changed, 335 insertions(+), 19 deletions(-) create mode 100644 services/worker-indexer/app/staging.py create mode 100644 services/worker-indexer/tests/test_staging.py diff --git a/README.md b/README.md index 2f67b57..415f3a4 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe 1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика). Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini). -2. **Юнит-тесты** — `pytest` по сервисам: 73 теста на ядро детекции и форматирования, - без внешней инфры (БД/Redis/GPU/Ollama замоканы либо не нужны). +2. **Юнит-тесты** — `pytest` по сервисам: 112 тестов на ядро детекции, скоринга, + парсеров и форматирования, без внешней инфры (БД/Redis/GPU/Ollama замоканы + либо не нужны). ```bash make lint # ruff + mypy в изолированном контейнере @@ -229,11 +230,14 @@ make test-one SVC=worker-gost # тесты одного сервиса | L1 — точные совпадения | `worker-indexer/app/algorithms/winnowing.py` | 13 | | L2 — нечёткие (MinHash LSH) | `worker-indexer/app/algorithms/minhash.py` | 6 | | Разбиение на фрагменты | `worker-indexer/app/fragments.py` | 5 | -| L3 — семантический индекс | `worker-gpu/app/faiss_manager.py` | 5 | +| Автопополнение корпуса | `worker-indexer/app/staging.py` | 6 | +| L3 — семантический индекс (FAISS) | `worker-gpu/app/faiss_manager.py` | 5 | +| L3 — векторный бэкенд (Qdrant + выбор) | `worker-gpu/app/qdrant_manager.py`, `vector_store.py` | 9 | | L4 — LLM-парафраз | `worker-gpu/app/ollama_client.py` | 12 | -| Итоговый % плагиата | `worker-gpu/app/scoring.py` | 8 | +| Итоговый % плагиата + цитаты | `worker-gpu/app/scoring.py` | 18 | | ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 | | Список литературы | `worker-gost/app/bibliography.py` | 7 | +| Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 | ## Лицензия diff --git a/scripts/run_lint.sh b/scripts/run_lint.sh index 7d97705..56af604 100755 --- a/scripts/run_lint.sh +++ b/scripts/run_lint.sh @@ -25,7 +25,7 @@ docker run --rm \ ruff check services/ scripts/ echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)' - ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py ) + ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py ) ( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ app/bibliography.py ) ( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py ) " diff --git a/services/frontend/src/components/PlagiarismReport.tsx b/services/frontend/src/components/PlagiarismReport.tsx index 4d26275..e5a92ae 100644 --- a/services/frontend/src/components/PlagiarismReport.tsx +++ b/services/frontend/src/components/PlagiarismReport.tsx @@ -66,6 +66,12 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {

Проверено фрагментов: {data.total_fragments} · Выявлено совпадений: {data.flagged_fragments}

+ {!!data.cited_fragments && ( +

+ из них корректно процитировано: {data.cited_fragments} · без указания источника:{' '} + {data.uncited_similarity?.toFixed(1)}% +

+ )} @@ -125,6 +131,14 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) { {METHOD_LABELS[match.method] || match.method} + {match.cited && ( + + Цитата + + )} {/* Фрагмент */}
diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index fbffb46..9e445b1 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -56,13 +56,22 @@ export interface PlagiarismMatch { source_title: string; source_url: string | null; source_db: string; + // Похоже на корректную цитату (кавычки/ссылка с годом рядом) — эвристика, + // см. app.scoring.is_cited. Опционально: старые задачи в БД посчитаны без + // этого поля. + cited?: boolean; } export interface PlagiarismResultData { overall_similarity: number; + // Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат. + // Опционально по той же причине, что и cited. + uncited_similarity?: number; matches: PlagiarismMatch[]; total_fragments: number; flagged_fragments: number; + cited_fragments?: number; + uncited_fragments?: number; by_method?: { exact: number; fuzzy: number; diff --git a/services/worker-gpu/app/scoring.py b/services/worker-gpu/app/scoring.py index a182191..69bf1e3 100644 --- a/services/worker-gpu/app/scoring.py +++ b/services/worker-gpu/app/scoring.py @@ -1,35 +1,87 @@ """Чистая доменная логика скоринга плагиата — без Celery/БД/сети. Объединяет совпадения всех уровней (1 winnowing, 2 minhash, 3+4 semantic+llm), -дедуплицирует их и считает итоговый процент схожести, который видит студент. -Вынесено из Celery-задачи, чтобы логику можно было тестировать изолированно. +дедуплицирует их, размечает корректно процитированные фрагменты и считает +итоговые проценты, которые видит студент. Вынесено из Celery-задачи, чтобы +логику можно было тестировать изолированно. """ +import re from typing import Any +# Кавычки (открывающая/закрывающая) — рус./англ. варианты +_OPEN_QUOTES = '«"„' +_CLOSE_QUOTES = '»"“”' + +# Скобочная конструкция с 4-значным годом внутри — [Иванов, 2023], (Smith, 2020), +# [Иванов и др., 2023]. Совпадает и с нашим же форматом ГОСТ 7.0.5. +_CITATION_RE = re.compile(r"[\[(][^\[\]()]{0,80}(?:19|20)\d{2}[^\[\]()]{0,20}[\])]") + +# Насколько далеко после фрагмента искать ссылку на источник +_CITATION_LOOKAHEAD = 150 + + +def is_cited(full_text: str, position_start: int, position_end: int) -> bool: + """Эвристика: похож ли фрагмент на корректно процитированный, а не на голый плагиат. + + Фрагмент считается процитированным, если: + - обрамлён кавычками («…», "…") сразу по границам, ИЛИ + - сразу за ним (в пределах ~150 символов) идёт скобочная ссылка с годом — + [Иванов, 2023], (Smith, 2020) и т.п. + + Эвристика, не 100%-но точная — сигнал для сортировки/отчёта, не финальный + вердикт. Для фрагментов без надёжной позиции в тексте (например, уровень 2 + MinHash — совпадение на уровне всего документа) возвращает False: их нельзя + локализовать, чтобы проверить окружение. + + Args: + full_text: полный текст проверяемого документа + position_start: начало фрагмента (символ) + position_end: конец фрагмента (символ) + + Returns: + True, если похоже на корректную цитату + """ + if not full_text or position_start < 0 or position_end > len(full_text): + return False + + before = full_text[max(0, position_start - 3) : position_start] + after = full_text[position_end : position_end + 3] + if any(c in before for c in _OPEN_QUOTES) and any(c in after for c in _CLOSE_QUOTES): + return True + + tail = full_text[position_end : position_end + _CITATION_LOOKAHEAD] + return bool(_CITATION_RE.search(tail)) + def aggregate_results( level1_matches: list[dict[str, Any]], level2_matches: list[dict[str, Any]], semantic_matches: list[dict[str, Any]], total_fragments: int, + full_text: str = "", ) -> dict[str, Any]: """Свести совпадения уровней в итог проверки. Дедупликация — по паре (source_title, position_start): одно и то же совпадение - источника в одной позиции не дублируется. Итоговый процент — доля УНИКАЛЬНЫХ + источника в одной позиции не дублируется. `overall_similarity` — доля УНИКАЛЬНЫХ помеченных позиций от всех фрагментов (фрагмент, совпавший с несколькими - источниками, не раздувает процент выше 100). + источниками, не раздувает процент выше 100). `uncited_similarity` — та же доля, + но БЕЗ фрагментов, похожих на корректную цитату (см. is_cited) — это ближе к + тому, что коммерческие системы называют «% некорректных заимствований». Args: level1_matches: совпадения уровня 1 (Winnowing, точные) level2_matches: совпадения уровня 2 (MinHash, нечёткие) semantic_matches: совпадения уровней 3-4 (FAISS + LLM-парафраз) total_fragments: всего проверенных фрагментов документа + full_text: полный текст проверяемого документа — нужен для is_cited; + пустая строка → ни один фрагмент не размечается как цитата Returns: - dict с полями overall_similarity, matches, total_fragments, - flagged_fragments, by_method. + dict с полями overall_similarity, uncited_similarity, matches (с полем + "cited" в каждом), total_fragments, flagged_fragments, cited_fragments, + uncited_fragments, by_method. """ all_matches = level1_matches + level2_matches + semantic_matches @@ -39,18 +91,24 @@ def aggregate_results( key = f"{m.get('source_title', '')}:{m.get('position_start', '')}" if key not in seen: seen.add(key) + m = {**m, "cited": is_cited(full_text, m.get("position_start", -1), m.get("position_end", -1))} unique_matches.append(m) flagged_positions = {m.get("position_start") for m in unique_matches} - flagged_frags = len(flagged_positions) - overall = (flagged_frags / total_fragments * 100) if total_fragments > 0 else 0.0 - overall = min(overall, 100.0) + uncited_positions = {m.get("position_start") for m in unique_matches if not m["cited"]} + + def _pct(positions: set) -> float: + pct = (len(positions) / total_fragments * 100) if total_fragments > 0 else 0.0 + return round(min(pct, 100.0), 2) return { - "overall_similarity": round(overall, 2), + "overall_similarity": _pct(flagged_positions), + "uncited_similarity": _pct(uncited_positions), "matches": unique_matches, "total_fragments": total_fragments, - "flagged_fragments": flagged_frags, + "flagged_fragments": len(flagged_positions), + "cited_fragments": len(flagged_positions) - len(uncited_positions), + "uncited_fragments": len(uncited_positions), "by_method": { "exact": len(level1_matches), "fuzzy": len(level2_matches), diff --git a/services/worker-gpu/app/tasks/plagiarism.py b/services/worker-gpu/app/tasks/plagiarism.py index b23c2ab..a24bf4b 100644 --- a/services/worker-gpu/app/tasks/plagiarism.py +++ b/services/worker-gpu/app/tasks/plagiarism.py @@ -146,7 +146,7 @@ def check_plagiarism( from app.scoring import aggregate_results result = aggregate_results( - level1_matches, level2_matches, semantic_matches, len(fragments) + level1_matches, level2_matches, semantic_matches, len(fragments), full_text=text ) # Сохранить результат @@ -171,6 +171,16 @@ def check_plagiarism( queue="queue.notify", ) + # Пополнить базу для сравнения (как у коммерческих систем — каждая + # проверенная работа сама становится источником для будущих проверок). + # Диспатчим ПОСЛЕ сохранения результата, чтобы работа не сматчилась + # сама с собой в только что посчитанном отчёте. + celery_app.send_task( + "index.auto_approve_submission", + args=[task_id], + queue="queue.index", + ) + return result except Exception as exc: diff --git a/services/worker-gpu/tests/test_scoring.py b/services/worker-gpu/tests/test_scoring.py index 5351b7d..dac11d8 100644 --- a/services/worker-gpu/tests/test_scoring.py +++ b/services/worker-gpu/tests/test_scoring.py @@ -1,12 +1,16 @@ """Юнит-тесты скоринга плагиата (итоговый процент, который видит студент).""" -from app.scoring import aggregate_results +from app.scoring import aggregate_results, is_cited def _m(title: str, pos: int) -> dict: return {"source_title": title, "position_start": pos} +def _mp(title: str, start: int, end: int) -> dict: + return {"source_title": title, "position_start": start, "position_end": end} + + def test_empty_input_is_zero(): out = aggregate_results([], [], [], total_fragments=10) assert out["overall_similarity"] == 0.0 @@ -67,3 +71,71 @@ def test_percentage_rounded_to_two_decimals(): # 1 из 3 → 33.333... → 33.33 out = aggregate_results([_m("A", 0)], [], [], total_fragments=3) assert out["overall_similarity"] == 33.33 + + +# ─── is_cited ─────────────────────────────────────────────────────────────── + +def test_is_cited_quoted_fragment(): + text = 'Автор пишет: «дословная цитата» и развивает мысль.' + start = text.index("дословная") + end = start + len("дословная цитата") + assert is_cited(text, start, end) is True + + +def test_is_cited_bracket_citation_with_year(): + text = "Текст фрагмента без кавычек [Иванов, 2023] продолжение." + end = text.index(" [Иванов") + assert is_cited(text, 0, end) is True + + +def test_is_cited_apa_style_citation(): + text = "Some borrowed sentence here (Smith, 2020) continues." + end = text.index(" (Smith") + assert is_cited(text, 0, end) is True + + +def test_is_cited_bare_plagiarism_is_false(): + text = "Просто скопированный текст без всякого оформления далее." + assert is_cited(text, 0, 10) is False + + +def test_is_cited_bracket_without_year_is_false(): + text = "Текст фрагмента [см. приложение] продолжение." + end = text.index(" [см") + assert is_cited(text, 0, end) is False + + +def test_is_cited_empty_full_text_is_false(): + assert is_cited("", 0, 5) is False + + +def test_is_cited_out_of_range_position_is_false(): + assert is_cited("короткий текст", 0, 999) is False + + +# ─── aggregate_results: cited/uncited split ────────────────────────────────── + +def test_cited_match_excluded_from_uncited_similarity(): + text = 'Вот «процитированный фрагмент» и текст дальше.' + start = text.index("процитированный") + end = start + len("процитированный фрагмент") + out = aggregate_results([_mp("A", start, end)], [], [], total_fragments=4, full_text=text) + assert out["matches"][0]["cited"] is True + assert out["overall_similarity"] == 25.0 # цитата всё ещё учтена в общем % + assert out["uncited_similarity"] == 0.0 # но не в "некорректных" заимствованиях + assert out["cited_fragments"] == 1 + assert out["uncited_fragments"] == 0 + + +def test_uncited_match_counts_in_both_percentages(): + text = "Скопированный без указания источника текст фрагмента дальше." + out = aggregate_results([_mp("A", 0, 20)], [], [], total_fragments=4, full_text=text) + assert out["matches"][0]["cited"] is False + assert out["overall_similarity"] == out["uncited_similarity"] == 25.0 + assert out["uncited_fragments"] == 1 + + +def test_no_full_text_means_nothing_marked_cited(): + out = aggregate_results([_m("A", 0)], [], [], total_fragments=2) # full_text не передан + assert out["matches"][0]["cited"] is False + assert out["overall_similarity"] == out["uncited_similarity"] diff --git a/services/worker-indexer/app/config.py b/services/worker-indexer/app/config.py index 4f6f4c5..dce4e51 100644 --- a/services/worker-indexer/app/config.py +++ b/services/worker-indexer/app/config.py @@ -59,6 +59,13 @@ class Settings(BaseSettings): FULL_TEXT_MIN_CHARS: int = 500 # Минимум символов, иначе считаем извлечение неудачным EMBED_BATCH_SIZE: int = 64 # Размер пачки документов для диспатча эмбеддингов + # Автоматически добавлять проверенные работы студентов в базу для сравнения + # (как в коммерческих системах — Антиплагиат.ру/Turnitin ловят списывание у + # предыдущих потоков именно так). Без этого каждая работа лежит в StagedWork + # и ждёт ручного одобрения админом — сейчас это дефолт для роста корпуса; + # выключить, если нужна модерация перед публикацией. + AUTO_APPROVE_SUBMISSIONS: bool = True + # App ENVIRONMENT: str = "development" DEBUG: bool = False diff --git a/services/worker-indexer/app/staging.py b/services/worker-indexer/app/staging.py new file mode 100644 index 0000000..7e9ff08 --- /dev/null +++ b/services/worker-indexer/app/staging.py @@ -0,0 +1,40 @@ +"""Преобразование StagedWork → doc_data для add_document — чистая логика. + +Формат идентичен ручному одобрению в админке (POST /admin/staging/{id}/approve), +чтобы автоматический путь (auto_approve_submission) и ручной давали одинаковый +результат. Без зависимости от SQLAlchemy/Celery/MinIO — принимает любой объект с +нужными атрибутами (StagedWork ORM или эквивалент), тестируется изолированно. +""" + +from typing import Any, Protocol + + +class StagedWorkLike(Protocol): + id: int + title: str | None + filename: str | None + authors: list | None + year: int | None + lang: str | None + + +def staged_work_to_doc_data(sw: StagedWorkLike, full_text: str) -> dict[str, Any]: + """Собрать doc_data для index.add_document из записи StagedWork. + + Args: + sw: StagedWork (или объект с теми же атрибутами) + full_text: извлечённый текст работы (уже прочитан из MinIO) + + Returns: + dict в формате, который ожидает add_document (source, ext_id, title, ...) + """ + return { + "source": "user_submission", + "ext_id": f"staged:{sw.id}", + "title": sw.title or sw.filename or f"Работа #{sw.id}", + "authors": sw.authors or [], + "year": sw.year, + "lang": sw.lang, + "abstract": full_text[:2000], + "full_text": full_text, + } diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py index 4518e58..f710112 100644 --- a/services/worker-indexer/app/tasks/index.py +++ b/services/worker-indexer/app/tasks/index.py @@ -1,7 +1,7 @@ """Celery задачи индексации документов и проверки плагиата (уровни 1-2).""" import io -from datetime import UTC +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -17,6 +17,7 @@ from app.db import db_session, get_minio, update_task_status from app.extractors.docx import extract_text_from_docx, extract_text_from_txt from app.extractors.pdf import extract_text_from_pdf from app.fragments import split_into_fragments +from app.staging import staged_work_to_doc_data logger = get_task_logger(__name__) @@ -390,6 +391,56 @@ def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]: return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)} +@celery_app.task( + name="index.auto_approve_submission", + bind=True, + max_retries=2, + default_retry_delay=60, +) +def auto_approve_submission(self, task_id: str) -> dict[str, Any]: + """Автоматически добавить проверенную работу студента в базу для сравнения. + + Вызывается из gpu.check_plagiarism ПОСЛЕ сохранения результата проверки — + так работа не сматчится сама с собой. Идемпотентно: пропускает, если + StagedWork не 'pending' (уже одобрена/отклонена вручную или повторный вызов) + или если AUTO_APPROVE_SUBMISSIONS выключен настройкой — тогда работа так и + остаётся ждать ручного решения в админке. + """ + from app.models import StagedWork + + if not settings.AUTO_APPROVE_SUBMISSIONS: + return {"status": "skipped", "reason": "auto-approve выключен настройкой"} + + with db_session() as session: + sw = session.execute( + select(StagedWork).where(StagedWork.task_id == task_id) + ).scalar_one_or_none() + if sw is None: + return {"status": "skipped", "reason": "StagedWork не найден"} + if sw.status != "pending": + return {"status": "skipped", "reason": f"уже {sw.status}"} + + full_text = "" + if sw.text_key: + try: + minio = get_minio() + obj = minio.get_object(settings.MINIO_BUCKET_STAGING, sw.text_key) + full_text = obj.read().decode("utf-8", errors="replace") + except Exception as e: + logger.warning(f"auto_approve_submission: не прочитан текст {task_id!r}: {e}") + return {"status": "error", "reason": str(e)} + + doc_data = staged_work_to_doc_data(sw, full_text) + sw.status = "approved" + sw.reviewed_by = None # None = одобрено автоматически, не человеком + sw.reviewed_at = datetime.now(UTC) + session.commit() + + result = add_document(doc_data, dispatch_embed=True) + logger.info(f"auto_approve_submission: задача {task_id!r} → {result}") + return result + + def _stage_work( task_id: str, minio_key: str, diff --git a/services/worker-indexer/tests/test_staging.py b/services/worker-indexer/tests/test_staging.py new file mode 100644 index 0000000..e1a8071 --- /dev/null +++ b/services/worker-indexer/tests/test_staging.py @@ -0,0 +1,51 @@ +"""Юнит-тесты преобразования StagedWork → doc_data (автопополнение корпуса).""" + +from dataclasses import dataclass + +from app.staging import staged_work_to_doc_data + + +@dataclass +class _SW: + id: int + title: str | None = None + filename: str | None = None + authors: list | None = None + year: int | None = None + lang: str | None = None + + +def test_ext_id_is_prefixed_with_staged(): + d = staged_work_to_doc_data(_SW(id=42), "текст") + assert d["ext_id"] == "staged:42" + + +def test_source_is_user_submission(): + d = staged_work_to_doc_data(_SW(id=1), "текст") + assert d["source"] == "user_submission" + + +def test_title_falls_back_to_filename_then_placeholder(): + assert staged_work_to_doc_data(_SW(id=1, title="Заголовок"), "т")["title"] == "Заголовок" + assert staged_work_to_doc_data(_SW(id=1, filename="work.pdf"), "т")["title"] == "work.pdf" + assert staged_work_to_doc_data(_SW(id=7), "т")["title"] == "Работа #7" + + +def test_authors_defaults_to_empty_list(): + assert staged_work_to_doc_data(_SW(id=1), "т")["authors"] == [] + assert staged_work_to_doc_data(_SW(id=1, authors=[{"last_name": "X"}]), "т")["authors"] == [ + {"last_name": "X"} + ] + + +def test_full_text_preserved_abstract_truncated_to_2000(): + long_text = "с" * 3000 + d = staged_work_to_doc_data(_SW(id=1), long_text) + assert d["full_text"] == long_text + assert len(d["abstract"]) == 2000 + + +def test_year_and_lang_passed_through(): + d = staged_work_to_doc_data(_SW(id=1, year=2024, lang="ru"), "т") + assert d["year"] == 2024 + assert d["lang"] == "ru"