[crawler_platform 삭제]
This commit is contained in:
436
ontology_platform/web/frontend/src/pages/ReviewPage.tsx
Normal file
436
ontology_platform/web/frontend/src/pages/ReviewPage.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Eye,
|
||||
Filter,
|
||||
ListChecks,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatPercent,
|
||||
humanizeValue,
|
||||
reviewLabel,
|
||||
} from "@/lib/display";
|
||||
import { Claim } from "@/lib/api/claims";
|
||||
|
||||
function statusVariant(status: string | undefined): BadgeProps["variant"] {
|
||||
switch (reviewLabel(status)) {
|
||||
case "approved":
|
||||
return "success";
|
||||
case "rejected":
|
||||
return "destructive";
|
||||
case "candidate":
|
||||
return "warning";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceBucket(claim: Claim): string {
|
||||
if (typeof claim.confidence !== "number") return "unknown";
|
||||
if (claim.confidence >= 0.9) return "auto-approve candidate";
|
||||
if (claim.confidence >= 0.7) return "normal review";
|
||||
return "priority review";
|
||||
}
|
||||
|
||||
function claimObject(claim: Claim): string {
|
||||
return humanizeValue(claim.object ?? claim.object_value);
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const claims = useClaims(projectName, {
|
||||
includeCandidates: true,
|
||||
limit: 300,
|
||||
});
|
||||
const updateStatus = useUpdateClaimStatus(projectName);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const filteredClaims = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (claims.data ?? []).filter((claim) => {
|
||||
if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!query) return true;
|
||||
return [
|
||||
claim.subject,
|
||||
claim.predicate,
|
||||
claimObject(claim),
|
||||
claim.evidence_text,
|
||||
claim.page_url,
|
||||
claim.source,
|
||||
]
|
||||
.map((value) => humanizeValue(value, "").toLowerCase())
|
||||
.some((value) => value.includes(query));
|
||||
});
|
||||
}, [claims.data, search, statusFilter]);
|
||||
|
||||
const selectedClaim = useMemo(() => {
|
||||
return (
|
||||
filteredClaims.find((claim) => claim.id === selectedId) ??
|
||||
filteredClaims[0]
|
||||
);
|
||||
}, [filteredClaims, selectedId]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const all = claims.data ?? [];
|
||||
return {
|
||||
total: all.length,
|
||||
candidate: all.filter((claim) => reviewLabel(claim.status) === "candidate")
|
||||
.length,
|
||||
approved: all.filter((claim) => reviewLabel(claim.status) === "approved")
|
||||
.length,
|
||||
rejected: all.filter((claim) => reviewLabel(claim.status) === "rejected")
|
||||
.length,
|
||||
};
|
||||
}, [claims.data]);
|
||||
|
||||
const applyStatus = async (claim: Claim, status: string) => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({
|
||||
claimId: claim.id,
|
||||
status,
|
||||
reason:
|
||||
status === "rejected"
|
||||
? "Rejected from Claim Review"
|
||||
: "Updated from Claim Review",
|
||||
});
|
||||
toast.success(`Claim ${status}`);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/schema/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<ListChecks className="h-6 w-6 text-primary" />
|
||||
Claim Review
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} · 후보, 승인, 반려 상태 분리 검토
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/quality/${projectName}`)}>
|
||||
Quality Inspector
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{claims.isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(claims.error as Error).message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<Metric label="Total" value={counts.total} />
|
||||
<Metric label="Candidate" value={counts.candidate} />
|
||||
<Metric label="Approved" value={counts.approved} />
|
||||
<Metric label="Rejected" value={counts.rejected} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_420px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Review Queue</CardTitle>
|
||||
<CardDescription>
|
||||
신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-56 pl-9"
|
||||
placeholder="Search claims"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Filter className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="w-40 pl-9"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="candidate">Candidate</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{claims.isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!claims.isLoading && filteredClaims.length === 0 && (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
조건에 맞는 클레임이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{filteredClaims.map((claim) => (
|
||||
<article
|
||||
key={claim.id}
|
||||
className="rounded-md border bg-background p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(claim.id)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant={statusVariant(claim.status)}>
|
||||
{reviewLabel(claim.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{claim.predicate}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{confidenceBucket(claim)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
{humanizeValue(claim.subject)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">-></span>
|
||||
<span className="font-medium">{claimObject(claim)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>Confidence {formatPercent(claim.confidence)}</span>
|
||||
<span>Source {humanizeValue(claim.source)}</span>
|
||||
<span>
|
||||
Evidence {claim.evidence_text ? "yes" : "missing"}
|
||||
</span>
|
||||
<span>
|
||||
Method {humanizeValue(claim.extraction_method)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedId(claim.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Detail
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyStatus(claim, "validated_claim")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyStatus(claim, "rejected")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Claim Detail</CardTitle>
|
||||
<CardDescription>
|
||||
근거, 출처, 검증 결과, 히스토리
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedClaim && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
클레임을 선택하세요.
|
||||
</p>
|
||||
)}
|
||||
{selectedClaim && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={statusVariant(selectedClaim.status)}>
|
||||
{reviewLabel(selectedClaim.status)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{formatPercent(selectedClaim.confidence)}
|
||||
</Badge>
|
||||
{selectedClaim.review_required && (
|
||||
<Badge variant="warning">review required</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DetailRow label="Subject" value={selectedClaim.subject} />
|
||||
<DetailRow label="Subject Type" value={selectedClaim.subject_type} />
|
||||
<DetailRow label="Predicate" value={selectedClaim.predicate} />
|
||||
<DetailRow
|
||||
label="Object"
|
||||
value={selectedClaim.object ?? selectedClaim.object_value}
|
||||
/>
|
||||
<DetailRow label="Source" value={selectedClaim.source} />
|
||||
<DetailRow label="Source URL" value={selectedClaim.page_url} />
|
||||
<DetailRow
|
||||
label="Page Type"
|
||||
value={selectedClaim.page_type}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Created By"
|
||||
value={selectedClaim.extraction_method}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Validation"
|
||||
value={
|
||||
selectedClaim.validation_status ??
|
||||
selectedClaim.graph_merge_status
|
||||
}
|
||||
/>
|
||||
{selectedClaim.graph_merge_reason && (
|
||||
<DetailRow
|
||||
label="Graph Reason"
|
||||
value={selectedClaim.graph_merge_reason}
|
||||
/>
|
||||
)}
|
||||
{selectedClaim.evidence_text && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">Evidence Text</h3>
|
||||
<div className="rounded-md bg-yellow-50 px-3 py-2 text-sm leading-relaxed text-yellow-950">
|
||||
{selectedClaim.evidence_text}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{selectedClaim.confidence_breakdown && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
Confidence Breakdown
|
||||
</h3>
|
||||
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
|
||||
{JSON.stringify(
|
||||
selectedClaim.confidence_breakdown,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
{selectedClaim.source_history &&
|
||||
selectedClaim.source_history.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
Source History
|
||||
</h3>
|
||||
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
|
||||
{JSON.stringify(selectedClaim.source_history, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
applyStatus(selectedClaim, "validated_claim")
|
||||
}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => applyStatus(selectedClaim, "rejected")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last seen {formatDateTime(selectedClaim.last_seen_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: unknown }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user