437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|