From d02596cedb4b2374c30044edb0756b58acc2143d Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 25 Aug 2026 14:32:16 +0500 Subject: [PATCH] =?UTF-8?q?feat(plagiarism):=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BF=D0=B0=D0=B4=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из staged_works.text_key (тот же текст, по которому считались position_start/position_end) — тот же MinIO-объект, что уже читает админка для превью. Фронтенд: сворачиваемый блок с текстом работы, где заимствования/цитаты/тематически близкие фрагменты подсвечены разными цветами с тултипом на источник. --- services/api/app/api/tasks.py | 38 +++++ services/frontend/src/api/client.ts | 3 + .../src/components/HighlightedDocument.tsx | 148 ++++++++++++++++++ .../src/components/PlagiarismReport.tsx | 6 +- services/frontend/src/pages/Task.tsx | 2 +- 5 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 services/frontend/src/components/HighlightedDocument.tsx diff --git a/services/api/app/api/tasks.py b/services/api/app/api/tasks.py index f3c2ed3..79e8888 100644 --- a/services/api/app/api/tasks.py +++ b/services/api/app/api/tasks.py @@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.minio_client import get_minio from app.core.security import get_current_user from app.database import get_db +from app.models.admin import StagedWork from app.models.task import Task from app.models.user import User from app.schemas.tasks import TaskResponse @@ -63,6 +65,42 @@ async def get_task( return TaskResponse.model_validate(task) +@router.get("/{public_id}/text") +async def get_task_text( + public_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """Полный текст проверенного документа — для подсветки совпадений в интерфейсе. + + Тот же текст, по которому worker-gpu считал position_start/position_end в + отчёте о плагиате (см. StagedWork.text_key — пишется в _stage_work при + извлечении, worker-indexer/app/tasks/index.py). Запись туда — best-effort + (сбой не валит саму проверку), поэтому текст доступен не для всех задач. + """ + result = await db.execute( + select(Task).where(Task.public_id == public_id, Task.user_id == current_user.id) + ) + task = result.scalar_one_or_none() + if task is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена") + + staged = ( + await db.execute(select(StagedWork).where(StagedWork.task_id == task.id)) + ).scalar_one_or_none() + if staged is None or not staged.text_key: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Текст работы недоступен") + + try: + obj = get_minio().get_object("staging", staged.text_key) + text = obj.read().decode("utf-8", errors="replace") + except Exception as e: + logger.warning(f"Не удалось прочитать текст задачи {public_id!r}: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Текст работы недоступен") from e + + return {"text": text} + + @router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_task( public_id: str, diff --git a/services/frontend/src/api/client.ts b/services/frontend/src/api/client.ts index b0b41ac..058bd9a 100644 --- a/services/frontend/src/api/client.ts +++ b/services/frontend/src/api/client.ts @@ -58,6 +58,9 @@ export const tasksApi = { get: (taskId: string) => api.get(`/tasks/${taskId}`), + getText: (taskId: string) => + api.get(`/tasks/${taskId}/text`), + delete: (taskId: string) => api.delete(`/tasks/${taskId}`), }; diff --git a/services/frontend/src/components/HighlightedDocument.tsx b/services/frontend/src/components/HighlightedDocument.tsx new file mode 100644 index 0000000..f47e5b8 --- /dev/null +++ b/services/frontend/src/components/HighlightedDocument.tsx @@ -0,0 +1,148 @@ +import { useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { tasksApi } from '../api/client'; +import type { PlagiarismMatch, PlagiarismRecommendation } from '../types'; + +interface HighlightedDocumentProps { + taskId: string; + matches: PlagiarismMatch[]; + recommendations?: PlagiarismRecommendation[]; +} + +type HighlightKind = 'uncited' | 'cited' | 'recommendation'; + +interface HighlightRange { + start: number; + end: number; + kind: HighlightKind; + label: string; +} + +interface Segment { + text: string; + kind: HighlightKind | null; + label: string; +} + +// Приоритет при пересечении диапазонов — некорректное заимствование важнее +// цитаты, цитата важнее просто «похоже по теме». +const KIND_PRIORITY: Record = { uncited: 3, cited: 2, recommendation: 1 }; + +const KIND_STYLE: Record = { + uncited: 'bg-red-200/70 text-red-900', + cited: 'bg-blue-100 text-blue-900', + recommendation: 'bg-teal-100 text-teal-900', +}; + +function buildSegments(text: string, ranges: HighlightRange[]): Segment[] { + if (!text) return []; + if (ranges.length === 0) return [{ text, kind: null, label: '' }]; + + const points = new Set([0, text.length]); + for (const r of ranges) { + points.add(Math.max(0, Math.min(r.start, text.length))); + points.add(Math.max(0, Math.min(r.end, text.length))); + } + const bounds = Array.from(points).sort((a, b) => a - b); + + const segments: Segment[] = []; + for (let i = 0; i < bounds.length - 1; i++) { + const segStart = bounds[i]; + const segEnd = bounds[i + 1]; + if (segStart >= segEnd) continue; + + const covering = ranges.filter((r) => r.start < segEnd && r.end > segStart); + if (covering.length === 0) { + segments.push({ text: text.slice(segStart, segEnd), kind: null, label: '' }); + continue; + } + covering.sort((a, b) => KIND_PRIORITY[b.kind] - KIND_PRIORITY[a.kind]); + const label = Array.from(new Set(covering.map((c) => c.label))).join(' · '); + segments.push({ text: text.slice(segStart, segEnd), kind: covering[0].kind, label }); + } + return segments; +} + +export function HighlightedDocument({ taskId, matches, recommendations }: HighlightedDocumentProps) { + const [open, setOpen] = useState(false); + + const { data: text, isLoading, isError } = useQuery({ + queryKey: ['task-text', taskId], + queryFn: () => tasksApi.getText(taskId).then((r) => r.data.text as string), + enabled: open, + retry: false, + staleTime: Infinity, + }); + + const ranges = useMemo(() => { + const fromMatches: HighlightRange[] = matches.map((m) => ({ + start: m.position_start, + end: m.position_end, + kind: m.cited ? 'cited' : 'uncited', + label: `${m.source_title} — ${m.similarity.toFixed(0)}%`, + })); + const fromRecs: HighlightRange[] = (recommendations || []).map((r) => ({ + start: r.position_start, + end: r.position_end, + kind: 'recommendation', + label: `Похоже по теме: ${r.source_title} — ${r.similarity.toFixed(0)}%`, + })); + return [...fromMatches, ...fromRecs]; + }, [matches, recommendations]); + + const segments = useMemo(() => (text ? buildSegments(text, ranges) : []), [text, ranges]); + + return ( +
+ + + {open && ( +
+ {isLoading && ( +
+ Загрузка текста... +
+ )} + {isError && ( +

+ Текст работы для этой проверки недоступен. +

+ )} + {!!text && ( + <> +
+ + заимствование + + + цитата + + + похоже по теме + +
+
+ {segments.map((seg, i) => + seg.kind ? ( + + {seg.text} + + ) : ( + {seg.text} + ) + )} +
+ + )} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/PlagiarismReport.tsx b/services/frontend/src/components/PlagiarismReport.tsx index 85f734d..309fa46 100644 --- a/services/frontend/src/components/PlagiarismReport.tsx +++ b/services/frontend/src/components/PlagiarismReport.tsx @@ -1,9 +1,11 @@ import { AlertTriangle, CheckCircle, Info, Lightbulb } from 'lucide-react'; import { clsx } from 'clsx'; import type { PlagiarismResultData } from '../types'; +import { HighlightedDocument } from './HighlightedDocument'; interface PlagiarismReportProps { data: PlagiarismResultData; + taskId: string; } function getSimilarityLevel(pct: number): { @@ -46,12 +48,14 @@ const METHOD_LABELS: Record = { 'semantic+llm': 'Семантика + LLM', }; -export function PlagiarismReport({ data }: PlagiarismReportProps) { +export function PlagiarismReport({ data, taskId }: PlagiarismReportProps) { const level = getSimilarityLevel(data.overall_similarity); const Icon = level.icon; return (
+ + {/* Итоговый показатель */}
diff --git a/services/frontend/src/pages/Task.tsx b/services/frontend/src/pages/Task.tsx index 9bee3f2..8724f21 100644 --- a/services/frontend/src/pages/Task.tsx +++ b/services/frontend/src/pages/Task.tsx @@ -104,7 +104,7 @@ export function Task() {

Результат проверки плагиата

- +
)}