feat(plagiarism): показывать текст работы с подсветкой совпадений
All checks were successful
Deploy / test (push) Successful in 3m49s
Deploy / deploy (push) Successful in 22s

Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из
staged_works.text_key (тот же текст, по которому считались
position_start/position_end) — тот же MinIO-объект, что уже читает
админка для превью. Фронтенд: сворачиваемый блок с текстом работы,
где заимствования/цитаты/тематически близкие фрагменты подсвечены
разными цветами с тултипом на источник.
This commit is contained in:
jze9
2026-08-25 14:32:16 +05:00
parent 53d0de3d71
commit d02596cedb
5 changed files with 195 additions and 2 deletions

View File

@@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.minio_client import get_minio
from app.core.security import get_current_user from app.core.security import get_current_user
from app.database import get_db from app.database import get_db
from app.models.admin import StagedWork
from app.models.task import Task from app.models.task import Task
from app.models.user import User from app.models.user import User
from app.schemas.tasks import TaskResponse from app.schemas.tasks import TaskResponse
@@ -63,6 +65,42 @@ async def get_task(
return TaskResponse.model_validate(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) @router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task( async def delete_task(
public_id: str, public_id: str,

View File

@@ -58,6 +58,9 @@ export const tasksApi = {
get: (taskId: string) => get: (taskId: string) =>
api.get(`/tasks/${taskId}`), api.get(`/tasks/${taskId}`),
getText: (taskId: string) =>
api.get(`/tasks/${taskId}/text`),
delete: (taskId: string) => delete: (taskId: string) =>
api.delete(`/tasks/${taskId}`), api.delete(`/tasks/${taskId}`),
}; };

View File

@@ -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<HighlightKind, number> = { uncited: 3, cited: 2, recommendation: 1 };
const KIND_STYLE: Record<HighlightKind, string> = {
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<number>([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<HighlightRange[]>(() => {
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 (
<div className="border border-gray-200 rounded-xl overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-sm font-medium text-gray-700"
>
Текст работы с подсветкой совпадений
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{open && (
<div className="p-4">
{isLoading && (
<div className="flex items-center gap-2 text-sm text-gray-400 py-6 justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> Загрузка текста...
</div>
)}
{isError && (
<p className="text-sm text-gray-400 py-6 text-center">
Текст работы для этой проверки недоступен.
</p>
)}
{!!text && (
<>
<div className="flex flex-wrap items-center gap-4 mb-3 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-red-200/70 inline-block" /> заимствование
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-blue-100 inline-block" /> цитата
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-teal-100 inline-block" /> похоже по теме
</span>
</div>
<div className="text-sm leading-relaxed text-gray-800 whitespace-pre-wrap max-h-[600px] overflow-y-auto pr-1">
{segments.map((seg, i) =>
seg.kind ? (
<span key={i} title={seg.label} className={`${KIND_STYLE[seg.kind]} rounded px-0.5 cursor-help`}>
{seg.text}
</span>
) : (
<span key={i}>{seg.text}</span>
)
)}
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { AlertTriangle, CheckCircle, Info, Lightbulb } from 'lucide-react'; import { AlertTriangle, CheckCircle, Info, Lightbulb } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { PlagiarismResultData } from '../types'; import type { PlagiarismResultData } from '../types';
import { HighlightedDocument } from './HighlightedDocument';
interface PlagiarismReportProps { interface PlagiarismReportProps {
data: PlagiarismResultData; data: PlagiarismResultData;
taskId: string;
} }
function getSimilarityLevel(pct: number): { function getSimilarityLevel(pct: number): {
@@ -46,12 +48,14 @@ const METHOD_LABELS: Record<string, string> = {
'semantic+llm': 'Семантика + LLM', 'semantic+llm': 'Семантика + LLM',
}; };
export function PlagiarismReport({ data }: PlagiarismReportProps) { export function PlagiarismReport({ data, taskId }: PlagiarismReportProps) {
const level = getSimilarityLevel(data.overall_similarity); const level = getSimilarityLevel(data.overall_similarity);
const Icon = level.icon; const Icon = level.icon;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<HighlightedDocument taskId={taskId} matches={data.matches} recommendations={data.recommendations} />
{/* Итоговый показатель */} {/* Итоговый показатель */}
<div className={clsx('p-6 rounded-xl border-2', level.bgColor, level.borderColor)}> <div className={clsx('p-6 rounded-xl border-2', level.bgColor, level.borderColor)}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -104,7 +104,7 @@ export function Task() {
<h2 className="text-base font-semibold text-gray-900 mb-4"> <h2 className="text-base font-semibold text-gray-900 mb-4">
Результат проверки плагиата Результат проверки плагиата
</h2> </h2>
<PlagiarismReport data={task.result as PlagiarismResultData} /> <PlagiarismReport data={task.result as PlagiarismResultData} taskId={taskId} />
</div> </div>
)} )}