feat(plagiarism): показывать текст работы с подсветкой совпадений
Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из
staged_works.text_key (тот же текст, по которому считались
position_start/position_end) — тот же MinIO-объект, что уже читает
админка для превью. Фронтенд: сворачиваемый блок с текстом работы,
где заимствования/цитаты/тематически близкие фрагменты подсвечены
разными цветами с тултипом на источник.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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}`),
|
||||||
};
|
};
|
||||||
|
|||||||
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 { 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user