feat(plagiarism): показывать текст работы с подсветкой совпадений
Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из
staged_works.text_key (тот же текст, по которому считались
position_start/position_end) — тот же MinIO-объект, что уже читает
админка для превью. Фронтенд: сворачиваемый блок с текстом работы,
где заимствования/цитаты/тематически близкие фрагменты подсвечены
разными цветами с тултипом на источник.
This commit is contained in:
@@ -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}`),
|
||||
};
|
||||
|
||||
148
services/frontend/src/components/HighlightedDocument.tsx
Normal file
148
services/frontend/src/components/HighlightedDocument.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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 (
|
||||
<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="flex items-center gap-4">
|
||||
|
||||
@@ -104,7 +104,7 @@ export function Task() {
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">
|
||||
Результат проверки плагиата
|
||||
</h2>
|
||||
<PlagiarismReport data={task.result as PlagiarismResultData} />
|
||||
<PlagiarismReport data={task.result as PlagiarismResultData} taskId={taskId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user